wazap-mcp 0.9.0 → 0.9.2
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 +17 -0
- package/README.md +20 -8
- package/dist/auth-state.js +4 -1
- package/dist/banner.js +9 -3
- package/dist/cli.js +265 -69
- package/dist/config.js +7 -1
- package/dist/connect.js +55 -29
- package/dist/doctor.js +17 -2
- package/dist/errors.js +1 -1
- package/dist/ids.js +1 -1
- package/dist/index.js +14 -11
- package/dist/settings.js +7 -6
- package/dist/setup.js +83 -0
- package/dist/tools.js +1 -1
- package/dist/ui.js +185 -0
- package/dist/whatsapp.js +27 -2
- package/package.json +2 -1
package/AGENT.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# wazap setup, driven by an agent
|
|
2
|
+
|
|
3
|
+
You are setting up WhatsApp access for the person you are working with. Use the pairing-code flow (it is plain text); never the QR.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
1. Run `npx wazap-mcp status --json`. If `linked` is true, skip to step 5.
|
|
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
|
+
3. Start the link IN THE BACKGROUND and keep reading its output:
|
|
10
|
+
`npx wazap-mcp login --phone <number> --no-writes --yes 2>&1` (or `--writes`).
|
|
11
|
+
Within ~10 s the output contains a line `pairing code: XXXX-XXXX`.
|
|
12
|
+
4. Show the person exactly: "On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead → enter XXXX-XXXX". Keep reading the output until `Linked as …` and then `Synced N chats …` appear (up to 3 minutes). If the output says the code expired, run step 3 again.
|
|
13
|
+
5. 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`.
|
|
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
|
+
7. Done when `get_status` returns `connected`. Then offer: "what did I miss on WhatsApp today?"
|
|
16
|
+
|
|
17
|
+
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.
|
package/README.md
CHANGED
|
@@ -19,18 +19,30 @@ WhatsApp multi-device protocol over a WebSocket.
|
|
|
19
19
|
The npm package is `wazap-mcp`; the command it installs is `wazap`.
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npx wazap-mcp
|
|
23
|
-
npx wazap-mcp connect claude-code # write the MCP entry for your agent
|
|
22
|
+
npx wazap-mcp setup
|
|
24
23
|
```
|
|
25
24
|
|
|
25
|
+
That is the whole install. It links your account, finds the MCP clients
|
|
26
|
+
installed on this machine, writes their config and tells you what to restart.
|
|
27
|
+
|
|
28
|
+
Or have your agent do it. Paste this:
|
|
29
|
+
|
|
30
|
+
*Set up WhatsApp for me: run `npx wazap-mcp setup --agent` and follow what it prints.*
|
|
31
|
+
|
|
26
32
|
Then ask your agent: *"what did I miss on WhatsApp today?"*
|
|
27
33
|
|
|
28
|
-
`
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
Below are the steps `setup` runs for you. Each is still its own command when you
|
|
35
|
+
want to run it by hand.
|
|
36
|
+
|
|
37
|
+
`npx wazap-mcp login` shows a QR code; scan it from **Settings → Linked devices
|
|
38
|
+
→ Link a device**. No camera handy, or linking over SSH? `npx wazap-mcp login --phone +15550100`
|
|
39
|
+
prints an 8-character code you type under *Link with phone number instead*.
|
|
31
40
|
It ends by asking whether the agent may send messages; the answer is no unless
|
|
32
41
|
you say yes, and `npx wazap-mcp config writes on` changes it later.
|
|
33
42
|
|
|
43
|
+
`npx wazap-mcp connect claude-code` writes the MCP entry for one client. The
|
|
44
|
+
table under **Connect a client** has the rest.
|
|
45
|
+
|
|
34
46
|
`npx wazap-mcp` on its own is safe to run: it prints where you stand and what to do
|
|
35
47
|
next, and starts no server. When something is off, `npx wazap-mcp status` is the
|
|
36
48
|
first thing to run — it checks Node, the data directory, the lock, the
|
|
@@ -122,7 +134,7 @@ wazap ships five [Agent Skills](https://agentskills.io) that teach an agent the
|
|
|
122
134
|
|
|
123
135
|
| Skill | What the agent does |
|
|
124
136
|
| --- | --- |
|
|
125
|
-
| `wazap-setup` | Diagnose with `wazap status`, link by pairing code, connect a client with `wazap connect`, repair an expired session |
|
|
137
|
+
| `wazap-setup` | Diagnose with `wazap status`, link by QR or pairing code, connect a client with `wazap connect`, repair an expired session |
|
|
126
138
|
| `whatsapp-inbox` | "What did I miss?" Triage into *needs you / FYI / noise*, ranked, plus forgotten replies. Read-only |
|
|
127
139
|
| `whatsapp-recall` | "Find the invoice Dan sent." Search with query variants, page back in time, download and read the file. Read-only |
|
|
128
140
|
| `whatsapp-groups` | Catch up on a 300-message group: decisions, dates, what is asked of you. Read-only |
|
|
@@ -218,7 +230,7 @@ Run wazap on a server of your own when the agent is not on your laptop: another
|
|
|
218
230
|
```bash
|
|
219
231
|
npm install -g wazap-mcp
|
|
220
232
|
sudo useradd --system --home /var/lib/wazap --create-home wazap
|
|
221
|
-
sudo -u wazap WAZAP_DATA_DIR=/var/lib/wazap wazap login --phone +
|
|
233
|
+
sudo -u wazap WAZAP_DATA_DIR=/var/lib/wazap wazap login --phone +15550100 # pairing code works over SSH
|
|
222
234
|
sudo -u wazap tee /var/lib/wazap/.env >/dev/null <<END
|
|
223
235
|
WAZAP_READ_TOKEN=$(openssl rand -hex 32)
|
|
224
236
|
WAZAP_WRITE_TOKEN=$(openssl rand -hex 32)
|
|
@@ -235,7 +247,7 @@ The unit binds loopback only. Put TLS in front with the two-line [`deploy/Caddyf
|
|
|
235
247
|
```bash
|
|
236
248
|
git clone https://github.com/razvangirgiz/wazap && cd wazap
|
|
237
249
|
printf 'WAZAP_READ_TOKEN=%s\nWAZAP_WRITE_TOKEN=%s\n' $(openssl rand -hex 32) $(openssl rand -hex 32) > .env
|
|
238
|
-
docker compose run --rm wazap login --phone +
|
|
250
|
+
docker compose run --rm wazap login --phone +15550100 # once; the session lands in the wazap-data volume
|
|
239
251
|
docker compose up -d
|
|
240
252
|
curl -s http://127.0.0.1:8766/healthz
|
|
241
253
|
```
|
package/dist/auth-state.js
CHANGED
|
@@ -106,7 +106,10 @@ export function readLinkedAccount(dir) {
|
|
|
106
106
|
catch {
|
|
107
107
|
throw new WazapError("SESSION_CORRUPT", `Stored credentials in ${dir} are unreadable.`, RESET_FIX);
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
// Baileys itself treats an account with `me` as linked (socket.js: `if (!creds.me)`
|
|
110
|
+
// pairs a device, else logs in). `registered` is only ever set by the
|
|
111
|
+
// pairing-code flow, so a QR-linked session would look unlinked forever.
|
|
112
|
+
if (!creds.me?.id)
|
|
110
113
|
return null;
|
|
111
114
|
const number = creds.me.id.split(":")[0].split("@")[0];
|
|
112
115
|
return { id: `${number}@s.whatsapp.net`, name: creds.me.name || "", number };
|
package/dist/banner.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import { brand, dim } from "./ui.js";
|
|
2
|
+
export const BANNER_ART = `██╗ ██╗ █████╗ ███████╗ █████╗ ██████╗
|
|
2
3
|
██║ ██║██╔══██╗╚══███╔╝██╔══██╗██╔══██╗
|
|
3
4
|
██║ █╗ ██║███████║ ███╔╝ ███████║██████╔╝
|
|
4
5
|
██║███╗██║██╔══██║ ███╔╝ ██╔══██║██╔═══╝
|
|
5
6
|
╚███╔███╔╝██║ ██║███████╗██║ ██║██║
|
|
6
|
-
╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝
|
|
7
|
-
WhatsApp for your AI agent
|
|
7
|
+
╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝`;
|
|
8
|
+
export const TAGLINE = "WhatsApp for your AI agent.";
|
|
9
|
+
export const BANNER = `${BANNER_ART}\n${TAGLINE}`;
|
|
10
|
+
/** Painted per line, so a wrapped terminal cannot bleed the colour onward. */
|
|
11
|
+
export function banner() {
|
|
12
|
+
return `${BANNER_ART.split("\n").map(brand).join("\n")}\n${dim(TAGLINE)}`;
|
|
13
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,10 +5,10 @@ import makeWASocket, { DisconnectReason, } from "baileys";
|
|
|
5
5
|
import qrcode from "qrcode";
|
|
6
6
|
import qrcodeTerminal from "qrcode-terminal";
|
|
7
7
|
import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
8
|
-
import {
|
|
8
|
+
import { banner } from "./banner.js";
|
|
9
9
|
import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
|
|
10
|
-
import {
|
|
11
|
-
import { checkLine, runChecks } from "./doctor.js";
|
|
10
|
+
import { connectNext } from "./connect.js";
|
|
11
|
+
import { checkLine, checkLines, runChecks } from "./doctor.js";
|
|
12
12
|
import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
|
|
13
13
|
import { normalizePhone } from "./ids.js";
|
|
14
14
|
import { lockHolder, releaseLock, writeLock } from "./lock.js";
|
|
@@ -16,6 +16,7 @@ import { log, logError, say } from "./logger.js";
|
|
|
16
16
|
import { formatAge } from "./messages.js";
|
|
17
17
|
import { runHttp, runStdio } from "./server.js";
|
|
18
18
|
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";
|
|
19
20
|
import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
|
|
20
21
|
const LOGIN_TIMEOUT_MS = 120_000;
|
|
21
22
|
const LIVE_TIMEOUT_MS = 15_000;
|
|
@@ -66,24 +67,8 @@ export async function runStatus(config) {
|
|
|
66
67
|
process.exit(0);
|
|
67
68
|
return report;
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
say("linked: no (credentials unreadable — run `wazap logout` then `wazap login`)");
|
|
72
|
-
}
|
|
73
|
-
else if (account) {
|
|
74
|
-
say("linked: yes");
|
|
75
|
-
say(`account: ${describeAccount(account)}`);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
say("linked: no");
|
|
79
|
-
}
|
|
80
|
-
say(`wazap: ${report.wazap_version}`);
|
|
81
|
-
say(`baileys: ${report.baileys_version}`);
|
|
82
|
-
say(report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`);
|
|
83
|
-
say("");
|
|
84
|
-
say("checks:");
|
|
85
|
-
for (const check of report.checks)
|
|
86
|
-
say(checkLine(check));
|
|
70
|
+
for (const line of humanLayout() ? richStatus(report) : plainStatus(report))
|
|
71
|
+
say(line);
|
|
87
72
|
if (report.live) {
|
|
88
73
|
say("");
|
|
89
74
|
for (const line of liveLines(report.live))
|
|
@@ -92,6 +77,40 @@ export async function runStatus(config) {
|
|
|
92
77
|
}
|
|
93
78
|
return report;
|
|
94
79
|
}
|
|
80
|
+
/** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
|
|
81
|
+
function plainStatus(report) {
|
|
82
|
+
const lines = [`data dir: ${report.data_dir}`];
|
|
83
|
+
if (!report.credentials_readable) {
|
|
84
|
+
lines.push("linked: no (credentials unreadable — run `wazap logout` then `wazap login`)");
|
|
85
|
+
}
|
|
86
|
+
else if (report.account) {
|
|
87
|
+
lines.push("linked: yes", `account: ${describeAccount(report.account)}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
lines.push("linked: no");
|
|
91
|
+
}
|
|
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));
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
const LABEL_WIDTH = 8;
|
|
96
|
+
function row(label, value) {
|
|
97
|
+
return `${dim(label.padEnd(LABEL_WIDTH))} ${value}`;
|
|
98
|
+
}
|
|
99
|
+
function richStatus(report) {
|
|
100
|
+
const account = !report.credentials_readable
|
|
101
|
+
? "credentials unreadable"
|
|
102
|
+
: report.account
|
|
103
|
+
? describeAccount(report.account)
|
|
104
|
+
: "not linked";
|
|
105
|
+
return [
|
|
106
|
+
`${bold(`wazap ${report.wazap_version}`)}${dim(` · baileys ${report.baileys_version}`)}`,
|
|
107
|
+
row("data dir", tilde(report.data_dir)),
|
|
108
|
+
row("account", account),
|
|
109
|
+
row("server", report.server_pid === null ? "not running" : `running (pid ${report.server_pid})`),
|
|
110
|
+
"",
|
|
111
|
+
...report.checks.flatMap(checkLines),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
95
114
|
function liveLines(live) {
|
|
96
115
|
if (!live.reachable)
|
|
97
116
|
return [`live: no connection (${live.reason ?? "unknown"})`];
|
|
@@ -114,13 +133,13 @@ async function runLiveProbe(config) {
|
|
|
114
133
|
const deadline = Date.now() + LIVE_TIMEOUT_MS;
|
|
115
134
|
try {
|
|
116
135
|
await wa.start();
|
|
117
|
-
let
|
|
118
|
-
while (!SETTLED_STATUSES.includes(
|
|
136
|
+
let probe = wa.getStatus();
|
|
137
|
+
while (!SETTLED_STATUSES.includes(probe.status) && Date.now() < deadline) {
|
|
119
138
|
await sleep(250);
|
|
120
|
-
|
|
139
|
+
probe = wa.getStatus();
|
|
121
140
|
}
|
|
122
|
-
if (
|
|
123
|
-
return { reachable: false, chats: null, last_message_age: null, reason:
|
|
141
|
+
if (probe.status !== "connected") {
|
|
142
|
+
return { reachable: false, chats: null, last_message_age: null, reason: probe.last_error ?? probe.status };
|
|
124
143
|
}
|
|
125
144
|
// listChats waits on its own sync gate, which would outlast the deadline.
|
|
126
145
|
const chats = await Promise.race([
|
|
@@ -144,32 +163,32 @@ async function runLiveProbe(config) {
|
|
|
144
163
|
}
|
|
145
164
|
/** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
|
|
146
165
|
export async function runGreet(config) {
|
|
147
|
-
say(
|
|
166
|
+
say(banner());
|
|
148
167
|
say("");
|
|
149
168
|
const report = await runStatus(config);
|
|
150
169
|
say("");
|
|
151
170
|
if (report.server_pid !== null) {
|
|
152
|
-
say(`A server is already running (pid ${report.server_pid}).`);
|
|
171
|
+
say(info(`A server is already running (pid ${report.server_pid}).`));
|
|
153
172
|
return;
|
|
154
173
|
}
|
|
155
174
|
if (!report.credentials_readable) {
|
|
156
|
-
say("
|
|
175
|
+
say(next("wazap logout", "(then wazap login)"));
|
|
157
176
|
return;
|
|
158
177
|
}
|
|
159
178
|
say(report.linked
|
|
160
|
-
?
|
|
161
|
-
: "
|
|
179
|
+
? next("wazap connect claude-code", '(then ask your agent: "what did I miss on WhatsApp today?")')
|
|
180
|
+
: next("wazap login"));
|
|
162
181
|
}
|
|
163
182
|
export async function runServe(config) {
|
|
164
183
|
const p = paths(config.dataDir);
|
|
165
184
|
const running = lockHolder(p.lockFile);
|
|
166
185
|
if (running !== null) {
|
|
167
|
-
say(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`);
|
|
186
|
+
say(fail(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`));
|
|
168
187
|
process.exit(2);
|
|
169
188
|
}
|
|
170
189
|
// Loopback with no token only gets runHttp's warning; off-loopback is refused.
|
|
171
190
|
if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
|
|
172
|
-
say(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`);
|
|
191
|
+
say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
|
|
173
192
|
process.exit(1);
|
|
174
193
|
}
|
|
175
194
|
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
@@ -192,45 +211,193 @@ export async function runServe(config) {
|
|
|
192
211
|
else
|
|
193
212
|
await runStdio(wa, config);
|
|
194
213
|
}
|
|
214
|
+
export function stepper(total) {
|
|
215
|
+
let n = 0;
|
|
216
|
+
return (title) => say(step(++n, total, title));
|
|
217
|
+
}
|
|
195
218
|
export async function runLogin(config) {
|
|
196
219
|
const p = paths(config.dataDir);
|
|
220
|
+
say(banner());
|
|
197
221
|
const linked = readLinkedAccount(p.authDir);
|
|
198
222
|
if (linked) {
|
|
199
|
-
say(`Already linked as ${describeAccount(linked)}. Run \`wazap logout\` to relink.`);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
const phone = config.loginQr ? null : normalizePhone(config.loginPhone ?? (await askPhone()));
|
|
203
|
-
let requested = false;
|
|
204
|
-
const onQr = async (qr, sock) => {
|
|
205
|
-
if (phone === null) {
|
|
206
|
-
qrcodeTerminal.generate(qr, { small: true }, (art) => say(art));
|
|
207
|
-
await qrcode.toFile(p.qrFile, qr);
|
|
208
|
-
say(`WhatsApp → Settings → Linked devices → Link a device, then scan the code above (also saved to ${p.qrFile}).`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (requested)
|
|
212
|
-
return;
|
|
213
|
-
requested = true;
|
|
214
|
-
const code = await sock.requestPairingCode(phone);
|
|
215
223
|
say("");
|
|
216
|
-
say(
|
|
217
|
-
say("");
|
|
218
|
-
say(` ${code.slice(0, 4)}-${code.slice(4)}`);
|
|
224
|
+
say(ok(`Already linked as ${describeAccount(linked)}`));
|
|
225
|
+
say(info("Run `wazap logout` to relink."));
|
|
219
226
|
say("");
|
|
227
|
+
say(connectNext());
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await linkAndSync(config, stepper(config.loginCode ? 4 : 3));
|
|
231
|
+
say("");
|
|
232
|
+
say(connectNext());
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Pairing owns the session the way the server does, so the lock is held from
|
|
237
|
+
* here until the history has landed. Writes are offered after it is released:
|
|
238
|
+
* that question edits .env, not the session, and it waits on a human.
|
|
239
|
+
*/
|
|
240
|
+
export async function linkAndSync(config, announce = () => { }) {
|
|
241
|
+
const p = paths(config.dataDir);
|
|
242
|
+
const running = lockHolder(p.lockFile);
|
|
243
|
+
if (running !== null) {
|
|
244
|
+
say(fail(`wazap is running (pid ${running}). Stop it first (or quit the client that launched it), then run this again.`));
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
248
|
+
writeLock(p.lockFile);
|
|
249
|
+
const release = () => releaseLock(p.lockFile);
|
|
250
|
+
const onInterrupt = () => {
|
|
251
|
+
release();
|
|
252
|
+
process.exit(130);
|
|
253
|
+
};
|
|
254
|
+
const onTerminate = () => {
|
|
255
|
+
release();
|
|
256
|
+
process.exit(143);
|
|
220
257
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
258
|
+
process.on("exit", release);
|
|
259
|
+
process.on("SIGINT", onInterrupt);
|
|
260
|
+
process.on("SIGTERM", onTerminate);
|
|
261
|
+
try {
|
|
262
|
+
let phone = null;
|
|
263
|
+
if (config.loginCode) {
|
|
264
|
+
announce("Your number");
|
|
265
|
+
phone = config.loginPhone === undefined ? await askPhone() : normalizePhone(config.loginPhone);
|
|
266
|
+
say(ok(maskNumber(phone)));
|
|
267
|
+
}
|
|
268
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
269
|
+
const waiting = new Countdown(deadline);
|
|
270
|
+
announce("Link your phone");
|
|
271
|
+
let requested = false;
|
|
272
|
+
const onQr = async (qr, sock) => {
|
|
273
|
+
if (phone === null) {
|
|
274
|
+
qrcodeTerminal.generate(qr, { small: true }, (art) => say(art));
|
|
275
|
+
await qrcode.toFile(p.qrFile, qr);
|
|
276
|
+
say(` Scan it with WhatsApp → Settings → Linked devices → Link a device (also saved to ${shortPath(p.qrFile)})`);
|
|
277
|
+
say(dim(" Prefer typing a code? Press Ctrl+C and run `wazap login --phone +15550100`."));
|
|
278
|
+
say("");
|
|
279
|
+
waiting.start();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (requested)
|
|
283
|
+
return;
|
|
284
|
+
requested = true;
|
|
285
|
+
const code = await sock.requestPairingCode(phone);
|
|
286
|
+
const pretty = `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
287
|
+
if (!humanLayout())
|
|
288
|
+
say(`pairing code: ${pretty}`);
|
|
289
|
+
say(" On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead");
|
|
290
|
+
say("");
|
|
291
|
+
say(box(pretty));
|
|
292
|
+
say("");
|
|
293
|
+
waiting.start();
|
|
294
|
+
};
|
|
295
|
+
let account;
|
|
296
|
+
try {
|
|
297
|
+
const sock = await linkSession(p.authDir, { deadline, onQr });
|
|
298
|
+
account = await settledAccount(sock, p.authDir);
|
|
299
|
+
await sock.end(undefined);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
waiting.stop();
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
waiting.stop(ok(`Linked as ${describeAccount(account)}`));
|
|
306
|
+
announce("Sync your chats");
|
|
307
|
+
await syncAfterLink(config);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
release();
|
|
311
|
+
process.off("exit", release);
|
|
312
|
+
process.off("SIGINT", onInterrupt);
|
|
313
|
+
process.off("SIGTERM", onTerminate);
|
|
314
|
+
}
|
|
315
|
+
announce("Permissions");
|
|
225
316
|
await offerWrites(config);
|
|
226
|
-
|
|
227
|
-
|
|
317
|
+
}
|
|
318
|
+
const HISTORY_WAIT_MS = 90_000;
|
|
319
|
+
const HISTORY_QUIET_MS = 3_000;
|
|
320
|
+
/**
|
|
321
|
+
* WhatsApp sends the chat history exactly once, right after pairing, to the
|
|
322
|
+
* socket that paired. The link socket has no store, so the real service takes
|
|
323
|
+
* over here and stays up until the history has landed and gone quiet.
|
|
324
|
+
*/
|
|
325
|
+
async function syncAfterLink(config) {
|
|
326
|
+
const wa = new WhatsAppService(config);
|
|
327
|
+
const spin = spinner("Syncing your chats…");
|
|
328
|
+
try {
|
|
329
|
+
await wa.start();
|
|
330
|
+
const deadline = Date.now() + HISTORY_WAIT_MS;
|
|
331
|
+
let seen = "";
|
|
332
|
+
let quietSince = Date.now();
|
|
333
|
+
while (Date.now() < deadline) {
|
|
334
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
335
|
+
const counts = wa.storeCounts();
|
|
336
|
+
const key = `${counts.chats}/${counts.contacts}/${counts.messages}`;
|
|
337
|
+
spin.update(`Syncing your chats… ${counts.chats} chats, ${counts.contacts} contacts, ${counts.messages} messages`);
|
|
338
|
+
if (!wa.hasHistory())
|
|
339
|
+
continue;
|
|
340
|
+
if (key !== seen) {
|
|
341
|
+
seen = key;
|
|
342
|
+
quietSince = Date.now();
|
|
343
|
+
}
|
|
344
|
+
else if (Date.now() - quietSince >= HISTORY_QUIET_MS) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
const counts = wa.storeCounts();
|
|
351
|
+
const got = wa.hasHistory();
|
|
352
|
+
await wa.stop();
|
|
353
|
+
spin.stop(got
|
|
354
|
+
? ok(`Synced ${counts.chats} chats, ${counts.contacts} contacts, ${counts.messages} messages`)
|
|
355
|
+
: warn("No history arrived in 90s. The server keeps listening; if chats stay empty, run `wazap logout` then `wazap login`."));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The spinner shown while WhatsApp is asked to accept the code, counting the
|
|
360
|
+
* pairing code down to the deadline it expires on.
|
|
361
|
+
*/
|
|
362
|
+
class Countdown {
|
|
363
|
+
deadline;
|
|
364
|
+
#spinner = null;
|
|
365
|
+
#ticker = null;
|
|
366
|
+
#stopped = false;
|
|
367
|
+
constructor(deadline) {
|
|
368
|
+
this.deadline = deadline;
|
|
369
|
+
}
|
|
370
|
+
/** No-op once stopped: the socket can settle while the code request is still in flight. */
|
|
371
|
+
start() {
|
|
372
|
+
if (this.#stopped)
|
|
373
|
+
return;
|
|
374
|
+
this.#spinner = spinner(this.#line());
|
|
375
|
+
this.#ticker = setInterval(() => this.#spinner?.update(this.#line()), 1_000);
|
|
376
|
+
this.#ticker.unref();
|
|
377
|
+
}
|
|
378
|
+
stop(final) {
|
|
379
|
+
this.#stopped = true;
|
|
380
|
+
if (this.#ticker !== null)
|
|
381
|
+
clearInterval(this.#ticker);
|
|
382
|
+
this.#ticker = null;
|
|
383
|
+
if (this.#spinner === null) {
|
|
384
|
+
if (final !== undefined)
|
|
385
|
+
say(final);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.#spinner.stop(final);
|
|
389
|
+
this.#spinner = null;
|
|
390
|
+
}
|
|
391
|
+
#line() {
|
|
392
|
+
const left = Math.max(0, Math.round((this.deadline - Date.now()) / 1_000));
|
|
393
|
+
return `Waiting for your phone… (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")})`;
|
|
394
|
+
}
|
|
228
395
|
}
|
|
229
396
|
export async function runLogout(config) {
|
|
230
397
|
const p = paths(config.dataDir);
|
|
231
398
|
const running = lockHolder(p.lockFile);
|
|
232
399
|
if (running !== null) {
|
|
233
|
-
say(`wazap is running (pid ${running}). Stop it first
|
|
400
|
+
say(fail(`wazap is running (pid ${running}). Stop it first.`));
|
|
234
401
|
process.exit(1);
|
|
235
402
|
}
|
|
236
403
|
let linked = null;
|
|
@@ -243,7 +410,7 @@ export async function runLogout(config) {
|
|
|
243
410
|
unreadable = true;
|
|
244
411
|
}
|
|
245
412
|
if (!linked && !unreadable) {
|
|
246
|
-
say("Not linked.");
|
|
413
|
+
say(info("Not linked."));
|
|
247
414
|
return;
|
|
248
415
|
}
|
|
249
416
|
if (linked) {
|
|
@@ -253,17 +420,34 @@ export async function runLogout(config) {
|
|
|
253
420
|
await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
|
|
254
421
|
}
|
|
255
422
|
catch (err) {
|
|
256
|
-
|
|
257
|
-
|
|
423
|
+
if (alreadyUnlinked(err)) {
|
|
424
|
+
say(info("WhatsApp had already unlinked this device."));
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
logError("unlink from WhatsApp", err);
|
|
428
|
+
say(warn("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed."));
|
|
429
|
+
}
|
|
258
430
|
}
|
|
259
431
|
}
|
|
260
432
|
clearAuth(p.authDir);
|
|
261
433
|
rmSync(p.storeFile, { force: true });
|
|
262
|
-
say("Logged out. Local credentials deleted.");
|
|
434
|
+
say(ok("Logged out. Local credentials deleted."));
|
|
263
435
|
process.exit(0);
|
|
264
436
|
}
|
|
265
|
-
|
|
266
|
-
|
|
437
|
+
/**
|
|
438
|
+
* WhatsApp answers 401 both when a pairing code was wrong and when the phone has
|
|
439
|
+
* already removed this device. At logout the second reading is the true one, so
|
|
440
|
+
* the pairing-time wording must not surface as an error here.
|
|
441
|
+
*/
|
|
442
|
+
export function alreadyUnlinked(err) {
|
|
443
|
+
if (err instanceof WazapError)
|
|
444
|
+
return err.code === "SESSION_EXPIRED";
|
|
445
|
+
return err?.output?.statusCode === DisconnectReason.loggedOut;
|
|
446
|
+
}
|
|
447
|
+
/** The number is masked: a status screenshot should not carry it. */
|
|
448
|
+
export function describeAccount(account) {
|
|
449
|
+
const number = maskNumber(account.number);
|
|
450
|
+
return account.name ? `${account.name} (${number})` : number;
|
|
267
451
|
}
|
|
268
452
|
/**
|
|
269
453
|
* Writes stay off unless the user says otherwise, so a fresh link cannot message
|
|
@@ -276,10 +460,10 @@ export async function offerWrites(config) {
|
|
|
276
460
|
}
|
|
277
461
|
if (config.assumeYes || !process.stdin.isTTY)
|
|
278
462
|
return;
|
|
279
|
-
const answer = await ask("Allow the agent to send messages, react and manage chats? [y/N]
|
|
463
|
+
const answer = await ask(`${brand("?")} Allow the agent to send messages, react and manage chats? [y/N] `);
|
|
280
464
|
applyWrites(config, /^y(es)?$/i.test(answer.trim()));
|
|
281
465
|
}
|
|
282
|
-
async function ask(question) {
|
|
466
|
+
export async function ask(question) {
|
|
283
467
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
284
468
|
try {
|
|
285
469
|
return await rl.question(question);
|
|
@@ -288,8 +472,20 @@ async function ask(question) {
|
|
|
288
472
|
rl.close();
|
|
289
473
|
}
|
|
290
474
|
}
|
|
291
|
-
|
|
292
|
-
|
|
475
|
+
const PHONE_ATTEMPTS = 3;
|
|
476
|
+
/** A typo costs another prompt, not the whole login. */
|
|
477
|
+
async function askPhone() {
|
|
478
|
+
for (let attempt = 1;; attempt++) {
|
|
479
|
+
const answer = await ask(`${brand("?")} WhatsApp number, international format (e.g. +15550100): `);
|
|
480
|
+
try {
|
|
481
|
+
return normalizePhone(answer);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
if (attempt === PHONE_ATTEMPTS)
|
|
485
|
+
throw err;
|
|
486
|
+
say(fail("Use international format, e.g. +15550100"));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
293
489
|
}
|
|
294
490
|
/**
|
|
295
491
|
* A socket run until it is open, retrying across the restart WhatsApp demands
|
package/dist/config.js
CHANGED
|
@@ -23,6 +23,7 @@ export function paths(dataDir) {
|
|
|
23
23
|
const COMMAND_ARGS = {
|
|
24
24
|
serve: [0],
|
|
25
25
|
login: [0],
|
|
26
|
+
setup: [0],
|
|
26
27
|
status: [0],
|
|
27
28
|
logout: [0],
|
|
28
29
|
connect: [1],
|
|
@@ -63,11 +64,14 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
63
64
|
port: { type: "string" },
|
|
64
65
|
phone: { type: "string" },
|
|
65
66
|
qr: { type: "boolean" },
|
|
67
|
+
code: { type: "boolean" },
|
|
66
68
|
"dry-run": { type: "boolean" },
|
|
67
69
|
live: { type: "boolean" },
|
|
68
70
|
json: { type: "boolean" },
|
|
69
71
|
writes: { type: "boolean" },
|
|
70
72
|
"no-writes": { type: "boolean" },
|
|
73
|
+
agent: { type: "boolean" },
|
|
74
|
+
client: { type: "string", multiple: true },
|
|
71
75
|
yes: { type: "boolean", short: "y" },
|
|
72
76
|
help: { type: "boolean", short: "h" },
|
|
73
77
|
version: { type: "boolean", short: "v" },
|
|
@@ -130,8 +134,10 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
130
134
|
live: values.live === true,
|
|
131
135
|
json: values.json === true,
|
|
132
136
|
loginPhone: values.phone,
|
|
133
|
-
|
|
137
|
+
loginCode: values.code === true || values.phone !== undefined,
|
|
134
138
|
writesAnswer: values.writes === true ? true : values["no-writes"] === true ? false : null,
|
|
139
|
+
agent: values.agent === true,
|
|
140
|
+
clients: values.client ?? [],
|
|
135
141
|
assumeYes: values.yes === true,
|
|
136
142
|
},
|
|
137
143
|
};
|
package/dist/connect.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { defaultDataDir } from "./config.js";
|
|
6
6
|
import { WazapError } from "./errors.js";
|
|
7
7
|
import { say } from "./logger.js";
|
|
8
|
+
import { dim, fail, fix, info, next, nextHint, ok, shortPath } from "./ui.js";
|
|
8
9
|
function claudeDesktopFile() {
|
|
9
10
|
if (process.platform === "darwin") {
|
|
10
11
|
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
@@ -22,6 +23,7 @@ export const CLIENTS = [
|
|
|
22
23
|
format: "command",
|
|
23
24
|
keyPath: [],
|
|
24
25
|
next: "Run `claude mcp list` to confirm.",
|
|
26
|
+
detect: (probe) => probe.onPath("claude"),
|
|
25
27
|
},
|
|
26
28
|
{
|
|
27
29
|
name: "claude-desktop",
|
|
@@ -30,6 +32,7 @@ export const CLIENTS = [
|
|
|
30
32
|
format: "json",
|
|
31
33
|
keyPath: ["mcpServers", "whatsapp"],
|
|
32
34
|
next: "Restart Claude Desktop.",
|
|
35
|
+
detect: (probe) => probe.exists(dirname(claudeDesktopFile())),
|
|
33
36
|
},
|
|
34
37
|
{
|
|
35
38
|
name: "cursor",
|
|
@@ -38,6 +41,7 @@ export const CLIENTS = [
|
|
|
38
41
|
format: "json",
|
|
39
42
|
keyPath: ["mcpServers", "whatsapp"],
|
|
40
43
|
next: "Reload the Cursor window.",
|
|
44
|
+
detect: (probe) => probe.exists(join(homedir(), ".cursor")),
|
|
41
45
|
},
|
|
42
46
|
{
|
|
43
47
|
name: "codex",
|
|
@@ -46,6 +50,7 @@ export const CLIENTS = [
|
|
|
46
50
|
format: "toml",
|
|
47
51
|
keyPath: ["mcp_servers", "whatsapp"],
|
|
48
52
|
next: "Restart Codex.",
|
|
53
|
+
detect: (probe) => probe.exists(join(homedir(), ".codex")),
|
|
49
54
|
},
|
|
50
55
|
{
|
|
51
56
|
name: "vscode",
|
|
@@ -55,6 +60,7 @@ export const CLIENTS = [
|
|
|
55
60
|
keyPath: ["servers", "whatsapp"],
|
|
56
61
|
extra: { type: "stdio" },
|
|
57
62
|
next: "Written to ./.vscode/mcp.json for this workspace. Reload the VS Code window.",
|
|
63
|
+
detect: (probe) => probe.onPath("code"),
|
|
58
64
|
},
|
|
59
65
|
{
|
|
60
66
|
name: "gemini",
|
|
@@ -63,21 +69,30 @@ export const CLIENTS = [
|
|
|
63
69
|
format: "json",
|
|
64
70
|
keyPath: ["mcpServers", "whatsapp"],
|
|
65
71
|
next: "Restart the Gemini CLI.",
|
|
72
|
+
detect: (probe) => probe.exists(join(homedir(), ".gemini")),
|
|
66
73
|
},
|
|
67
74
|
];
|
|
68
75
|
export const CLIENT_NAMES = CLIENTS.map((client) => client.name).join(", ");
|
|
69
|
-
|
|
76
|
+
const OTHER_CLIENTS = CLIENTS.slice(1)
|
|
70
77
|
.map((client) => client.name)
|
|
71
|
-
.join(", ")
|
|
78
|
+
.join(", ");
|
|
79
|
+
export function connectNext() {
|
|
80
|
+
return next("wazap connect claude-code", `(or ${OTHER_CLIENTS})`);
|
|
81
|
+
}
|
|
72
82
|
export function isNpxPath(binPath) {
|
|
73
83
|
return /[\\/]_npx[\\/]/.test(binPath);
|
|
74
84
|
}
|
|
75
|
-
const
|
|
76
|
-
export function
|
|
85
|
+
const PATH_EXTENSIONS = process.platform === "win32" ? [".cmd", ".exe", ""] : [""];
|
|
86
|
+
export function commandOnPath(name, pathEnv = process.env.PATH ?? "", exists = existsSync) {
|
|
77
87
|
return pathEnv
|
|
78
88
|
.split(delimiter)
|
|
79
89
|
.filter(Boolean)
|
|
80
|
-
.some((dir) =>
|
|
90
|
+
.some((dir) => PATH_EXTENSIONS.some((ext) => exists(join(dir, `${name}${ext}`))));
|
|
91
|
+
}
|
|
92
|
+
export const REAL_PROBES = { exists: existsSync, onPath: (command) => commandOnPath(command) };
|
|
93
|
+
/** The installed clients, in table order. */
|
|
94
|
+
export function detectClients(probe = REAL_PROBES) {
|
|
95
|
+
return CLIENTS.filter((client) => client.detect(probe));
|
|
81
96
|
}
|
|
82
97
|
/**
|
|
83
98
|
* How the client should launch wazap, from how it was launched now: through
|
|
@@ -88,7 +103,7 @@ export function onPath(pathEnv, exists = existsSync) {
|
|
|
88
103
|
export function launcher(binPath, pathEnv, exists) {
|
|
89
104
|
if (isNpxPath(binPath))
|
|
90
105
|
return { command: "npx", args: ["-y", "wazap-mcp"] };
|
|
91
|
-
if (
|
|
106
|
+
if (commandOnPath("wazap", pathEnv, exists))
|
|
92
107
|
return { command: "wazap", args: [] };
|
|
93
108
|
return { command: "node", args: [resolve(binPath)] };
|
|
94
109
|
}
|
|
@@ -105,14 +120,21 @@ const WRITERS = {
|
|
|
105
120
|
json: writeJsonEntry,
|
|
106
121
|
toml: writeTomlEntry,
|
|
107
122
|
};
|
|
108
|
-
export function
|
|
109
|
-
const name = config.args[0] ?? "";
|
|
123
|
+
export function findClient(name) {
|
|
110
124
|
const spec = CLIENTS.find((client) => client.name === name);
|
|
111
125
|
if (!spec) {
|
|
112
126
|
throw new WazapError("INVALID_ID", `Unknown client "${name}".`, `Pick one of: ${CLIENT_NAMES}`);
|
|
113
127
|
}
|
|
128
|
+
return spec;
|
|
129
|
+
}
|
|
130
|
+
export function connectClient(spec, config) {
|
|
114
131
|
WRITERS[spec.format](spec, mcpEntry(config), config.dryRun);
|
|
115
132
|
}
|
|
133
|
+
export function runConnect(config) {
|
|
134
|
+
const spec = findClient(config.args[0] ?? "");
|
|
135
|
+
connectClient(spec, config);
|
|
136
|
+
say(nextHint(spec.next));
|
|
137
|
+
}
|
|
116
138
|
/** Null only when the file is absent; an unreadable file must never be overwritten. */
|
|
117
139
|
function readTextOrNull(file) {
|
|
118
140
|
try {
|
|
@@ -153,7 +175,9 @@ function writeJsonEntry(spec, entry, dryRun) {
|
|
|
153
175
|
}
|
|
154
176
|
const value = { ...spec.extra, command: entry.command, args: entry.args };
|
|
155
177
|
setIn(doc, spec.keyPath, value);
|
|
156
|
-
|
|
178
|
+
// Indent 1 collapsed to one line: short enough to read, still spaced like JSON.
|
|
179
|
+
const shown = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ");
|
|
180
|
+
apply(spec, file, `${JSON.stringify(doc, null, 2)}\n`, current, dryRun, shown);
|
|
157
181
|
}
|
|
158
182
|
function writeTomlEntry(spec, entry, dryRun) {
|
|
159
183
|
const file = spec.file();
|
|
@@ -180,43 +204,45 @@ function spliceTomlTable(text, header, block) {
|
|
|
180
204
|
const merged = [...lines.slice(0, start), ...block.trimEnd().split("\n"), ...lines.slice(end)].join("\n");
|
|
181
205
|
return merged.endsWith("\n") ? merged : `${merged}\n`;
|
|
182
206
|
}
|
|
183
|
-
function apply(spec, file,
|
|
207
|
+
function apply(spec, file, content, current, dryRun, shown) {
|
|
208
|
+
const where = shortPath(file);
|
|
209
|
+
const entry = shown.split("\n").map((line) => ` ${dim(line)}`);
|
|
184
210
|
if (dryRun) {
|
|
185
|
-
say(`${spec.describe}
|
|
186
|
-
|
|
187
|
-
|
|
211
|
+
say(info(`${spec.describe} \u00b7 would write ${where}`));
|
|
212
|
+
for (const line of entry)
|
|
213
|
+
say(line);
|
|
188
214
|
return;
|
|
189
215
|
}
|
|
190
|
-
if (
|
|
191
|
-
say(`${spec.describe}
|
|
192
|
-
say(`Next: ${spec.next}`);
|
|
216
|
+
if (content === current) {
|
|
217
|
+
say(ok(`${spec.describe} \u00b7 ${where} already has this entry`));
|
|
193
218
|
return;
|
|
194
219
|
}
|
|
195
220
|
mkdirSync(dirname(file), { recursive: true });
|
|
196
|
-
|
|
221
|
+
let backup = "";
|
|
222
|
+
if (current !== null && !existsSync(`${file}.bak`)) {
|
|
197
223
|
copyFileSync(file, `${file}.bak`);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
say(
|
|
224
|
+
backup = ` (backup: ${basename(file)}.bak)`;
|
|
225
|
+
}
|
|
226
|
+
writeFileSync(file, content);
|
|
227
|
+
say(ok(`${spec.describe} \u00b7 wrote ${where}${backup}`));
|
|
228
|
+
for (const line of entry)
|
|
229
|
+
say(line);
|
|
202
230
|
}
|
|
203
231
|
function runClientCommand(spec, entry, dryRun) {
|
|
204
232
|
const argv = ["mcp", "add", "whatsapp", "--", entry.command, ...entry.args];
|
|
205
233
|
const shown = `claude ${argv.join(" ")}`;
|
|
206
234
|
if (dryRun) {
|
|
207
|
-
say(`${spec.describe}
|
|
208
|
-
say(shown);
|
|
209
|
-
say(`Next: ${spec.next}`);
|
|
235
|
+
say(info(`${spec.describe} \u00b7 would run`));
|
|
236
|
+
say(` ${dim(shown)}`);
|
|
210
237
|
return;
|
|
211
238
|
}
|
|
212
239
|
const result = spawnSync("claude", argv, { stdio: "inherit" });
|
|
213
240
|
if (result.error !== undefined) {
|
|
214
|
-
say("`claude` is not on PATH.
|
|
215
|
-
say(shown);
|
|
241
|
+
say(fail("`claude` is not on PATH."));
|
|
242
|
+
say(fix(`Run this where Claude Code is installed: ${shown}`));
|
|
216
243
|
process.exit(1);
|
|
217
244
|
}
|
|
218
245
|
if (result.status !== 0)
|
|
219
246
|
process.exit(result.status ?? 1);
|
|
220
|
-
say(`${spec.describe}
|
|
221
|
-
say(`Next: ${spec.next}`);
|
|
247
|
+
say(ok(`${spec.describe} \u00b7 registered via claude mcp add`));
|
|
222
248
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -2,7 +2,10 @@ import { accessSync, constants, statSync } from "node:fs";
|
|
|
2
2
|
import { readLinkedAccount } from "./auth-state.js";
|
|
3
3
|
import { WAZAP_VERSION, paths } from "./config.js";
|
|
4
4
|
import { lockHolder, lockPid } from "./lock.js";
|
|
5
|
+
import { dim, fail, fix, green, info, ok, red } from "./ui.js";
|
|
5
6
|
export const MARK = { ok: "✓", fail: "✗", info: "–" };
|
|
7
|
+
const GLYPH = { ok, fail, info };
|
|
8
|
+
const TINT = { ok: green, fail: red, info: dim };
|
|
6
9
|
const UPDATE_TIMEOUT_MS = 2_000;
|
|
7
10
|
const MIN_NODE_MAJOR = 20;
|
|
8
11
|
const CHECKS = [checkNode, checkDataDir, checkLock, checkCredentials, checkWrites, checkUpdate];
|
|
@@ -12,8 +15,19 @@ export async function runChecks(config) {
|
|
|
12
15
|
checks.push(await check(config));
|
|
13
16
|
return checks;
|
|
14
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* One line, everything on it. What pipes, logs and captured output get. Colour
|
|
20
|
+
* wraps the whole line rather than just the glyph: an escape landing between
|
|
21
|
+
* the mark and the name would split phrases that callers grep for.
|
|
22
|
+
*/
|
|
15
23
|
export function checkLine(check) {
|
|
16
|
-
|
|
24
|
+
const body = `${MARK[check.state]} ${check.name}: ${check.detail}${check.fix ? ` — ${check.fix}` : ""}`;
|
|
25
|
+
return TINT[check.state](body);
|
|
26
|
+
}
|
|
27
|
+
/** The same check for a human: no colon, and the repair on its own line. */
|
|
28
|
+
export function checkLines(check) {
|
|
29
|
+
const head = GLYPH[check.state](`${check.name} ${check.detail}`);
|
|
30
|
+
return check.fix === undefined ? [head] : [head, fix(check.fix)];
|
|
17
31
|
}
|
|
18
32
|
function checkNode() {
|
|
19
33
|
const version = process.versions.node;
|
|
@@ -66,9 +80,10 @@ function checkCredentials(config) {
|
|
|
66
80
|
const authDir = paths(config.dataDir).authDir;
|
|
67
81
|
try {
|
|
68
82
|
const account = readLinkedAccount(authDir);
|
|
83
|
+
// The number is deliberately absent: status is the thing people screenshot.
|
|
69
84
|
return account === null
|
|
70
85
|
? { name: "credentials", state: "info", detail: "no account linked yet" }
|
|
71
|
-
: { name: "credentials", state: "ok", detail:
|
|
86
|
+
: { name: "credentials", state: "ok", detail: "readable" };
|
|
72
87
|
}
|
|
73
88
|
catch (err) {
|
|
74
89
|
const wazap = err;
|
package/dist/errors.js
CHANGED
|
@@ -17,7 +17,7 @@ export const ERROR_GUIDE = {
|
|
|
17
17
|
SESSION_CORRUPT: "Stored credentials are unreadable. Tell the user to run `npx wazap-mcp logout` then `npx wazap-mcp login`.",
|
|
18
18
|
NOT_CONNECTED: "The socket is still connecting or reconnecting. Call get_status, wait, retry once.",
|
|
19
19
|
SYNC_IN_PROGRESS: "History sync has not finished. Retry in a few seconds; earlier messages may be missing until then.",
|
|
20
|
-
INVALID_PHONE: "The number is not in international format. Ask the user for a number with a country code, e.g. +
|
|
20
|
+
INVALID_PHONE: "The number is not in international format. Ask the user for a number with a country code, e.g. +15550100.",
|
|
21
21
|
INVALID_ID: "The id is not a WhatsApp chat, contact or group id. Use an id exactly as returned by another tool.",
|
|
22
22
|
NOT_ON_WHATSAPP: "That number has no WhatsApp account. Do not retry; confirm the number with the user.",
|
|
23
23
|
CHAT_NOT_FOUND: "No such chat is known. Call list_chats or search_contacts to get a valid chat_id.",
|
package/dist/ids.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WazapError } from "./errors.js";
|
|
2
|
-
const PHONE_EXAMPLE = "Use international format, e.g. +
|
|
2
|
+
const PHONE_EXAMPLE = "Use international format, e.g. +15550100";
|
|
3
3
|
/** Digits of a phone number in international format, or INVALID_PHONE. */
|
|
4
4
|
export function normalizePhone(input) {
|
|
5
5
|
const digits = input.trim().replace(/^\+/, "").replace(/[\s\-().]/g, "");
|
package/dist/index.js
CHANGED
|
@@ -3,14 +3,17 @@ import { BANNER } from "./banner.js";
|
|
|
3
3
|
import { 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
|
+
import { runSetup } from "./setup.js";
|
|
6
7
|
import { runConfig } from "./settings.js";
|
|
7
8
|
import { WazapError } from "./errors.js";
|
|
8
9
|
import { say } from "./logger.js";
|
|
10
|
+
import { fail, fix } from "./ui.js";
|
|
9
11
|
const USAGE = `${BANNER}
|
|
10
12
|
|
|
11
13
|
Usage:
|
|
12
14
|
wazap [serve] [--http] [--host <host>] [--port <port>] Run the MCP server (default: stdio)
|
|
13
|
-
wazap login [--phone +
|
|
15
|
+
wazap login [--phone +15550100] [--code] Link a WhatsApp account (QR by default)
|
|
16
|
+
wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
|
|
14
17
|
wazap connect <client> [--dry-run] Register wazap with an MCP client
|
|
15
18
|
wazap config [writes on|off] Show the effective settings, or allow/refuse writes
|
|
16
19
|
wazap status [--live] [--json] Check the install, the session and the server
|
|
@@ -24,8 +27,10 @@ Options:
|
|
|
24
27
|
--http Serve Streamable HTTP instead of stdio
|
|
25
28
|
--host <host> HTTP bind address (default 127.0.0.1)
|
|
26
29
|
--port <port> HTTP port (default 8766)
|
|
27
|
-
--
|
|
28
|
-
--
|
|
30
|
+
--code Log in with an 8-character pairing code instead of the QR
|
|
31
|
+
--phone <number> Your number in international format; implies --code
|
|
32
|
+
--agent With setup: print the procedure for an AI agent on stdout, then exit
|
|
33
|
+
--client <name> With setup: connect this client instead of the detected ones (repeatable)
|
|
29
34
|
--dry-run With connect: print what would be written, and write nothing
|
|
30
35
|
--live With status: reach WhatsApp for real, then close the connection
|
|
31
36
|
--json With status: print the whole report as one JSON object on stdout
|
|
@@ -61,6 +66,9 @@ async function main() {
|
|
|
61
66
|
case "login":
|
|
62
67
|
await runLogin(config);
|
|
63
68
|
return;
|
|
69
|
+
case "setup":
|
|
70
|
+
await runSetup(config);
|
|
71
|
+
return;
|
|
64
72
|
case "connect":
|
|
65
73
|
runConnect(config);
|
|
66
74
|
return;
|
|
@@ -76,13 +84,8 @@ async function main() {
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
main().catch((err) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
say(err.fix);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
say(err instanceof Error ? err.message : String(err));
|
|
86
|
-
}
|
|
87
|
+
say(fail(err instanceof Error ? err.message : String(err)));
|
|
88
|
+
if (err instanceof WazapError && err.fix)
|
|
89
|
+
say(fix(err.fix));
|
|
87
90
|
process.exit(1);
|
|
88
91
|
});
|
package/dist/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ import { paths } from "./config.js";
|
|
|
4
4
|
import { WazapError } from "./errors.js";
|
|
5
5
|
import { lockHolder } from "./lock.js";
|
|
6
6
|
import { say } from "./logger.js";
|
|
7
|
+
import { dim, ok, shortPath, warn } from "./ui.js";
|
|
7
8
|
/** Replace `KEY=` in place, keeping every other line, or append it. */
|
|
8
9
|
export function setEnvSetting(envFile, key, value) {
|
|
9
10
|
let text = "";
|
|
@@ -51,7 +52,7 @@ export function runConfig(config) {
|
|
|
51
52
|
for (const row of SETTINGS)
|
|
52
53
|
say(`${row.label}: ${row.value(config)} (${config.sources[row.source]})`);
|
|
53
54
|
say("");
|
|
54
|
-
say("Change writes with `wazap config writes on|off`.");
|
|
55
|
+
say(dim("Change writes with `wazap config writes on|off`."));
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
const [setting, value] = config.args;
|
|
@@ -64,11 +65,11 @@ export function runConfig(config) {
|
|
|
64
65
|
export function applyWrites(config, allowWrites) {
|
|
65
66
|
const p = paths(config.dataDir);
|
|
66
67
|
setEnvSetting(p.envFile, "WAZAP_READ_ONLY", allowWrites ? "0" : "1");
|
|
67
|
-
say(allowWrites
|
|
68
|
-
?
|
|
69
|
-
:
|
|
70
|
-
say(`Stored in ${p.envFile}.`);
|
|
68
|
+
say(ok(allowWrites
|
|
69
|
+
? "writes: on — the agent can send messages, react and manage chats. Turn it off with `wazap config writes off`."
|
|
70
|
+
: "writes: off — the agent can only read. Turn it on with `wazap config writes on`."));
|
|
71
|
+
say(dim(`Stored in ${shortPath(p.envFile)}.`));
|
|
71
72
|
const running = lockHolder(p.lockFile);
|
|
72
73
|
if (running !== null)
|
|
73
|
-
say(`A server is running (pid ${running}); restart it for this to apply.`);
|
|
74
|
+
say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
|
|
74
75
|
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readLinkedAccount } from "./auth-state.js";
|
|
3
|
+
import { banner } from "./banner.js";
|
|
4
|
+
import { ask, describeAccount, linkAndSync, stepper } from "./cli.js";
|
|
5
|
+
import { paths } from "./config.js";
|
|
6
|
+
import { CLIENTS, connectClient, connectNext, detectClients, findClient } from "./connect.js";
|
|
7
|
+
import { say } from "./logger.js";
|
|
8
|
+
import { brand, fail, info, ok } from "./ui.js";
|
|
9
|
+
export async function runSetup(config) {
|
|
10
|
+
// The whole output is the document and this command never serves, so stdout is
|
|
11
|
+
// the right channel. AGENT.md sits at the package root, which is what `files`
|
|
12
|
+
// publishes.
|
|
13
|
+
if (config.agent) {
|
|
14
|
+
process.stdout.write(readFileSync(new URL("../AGENT.md", import.meta.url), "utf8"));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
say(banner());
|
|
18
|
+
const announce = stepper(3);
|
|
19
|
+
announce("Link");
|
|
20
|
+
const account = readLinkedAccount(paths(config.dataDir).authDir);
|
|
21
|
+
if (account)
|
|
22
|
+
say(ok(`Already linked as ${describeAccount(account)}`));
|
|
23
|
+
else
|
|
24
|
+
await linkAndSync(config);
|
|
25
|
+
announce("Connect");
|
|
26
|
+
const chosen = await chooseClients(config);
|
|
27
|
+
if (chosen.length === 0) {
|
|
28
|
+
say(info("No client connected yet."));
|
|
29
|
+
say(connectNext());
|
|
30
|
+
}
|
|
31
|
+
for (const spec of chosen)
|
|
32
|
+
connectClient(spec, config);
|
|
33
|
+
announce("Finish");
|
|
34
|
+
say(ok("Setup complete"));
|
|
35
|
+
for (const spec of chosen)
|
|
36
|
+
say(info(spec.next));
|
|
37
|
+
say("");
|
|
38
|
+
say('Ask your agent: "what did I miss on WhatsApp today?"');
|
|
39
|
+
}
|
|
40
|
+
const CHOICE_ATTEMPTS = 3;
|
|
41
|
+
/**
|
|
42
|
+
* The whole table is printed, not only what was found: that is what makes the
|
|
43
|
+
* numbers and "all" mean something, and it lets someone pick a client the
|
|
44
|
+
* probes missed.
|
|
45
|
+
*/
|
|
46
|
+
async function chooseClients(config) {
|
|
47
|
+
if (config.clients.length > 0)
|
|
48
|
+
return config.clients.map(findClient);
|
|
49
|
+
const detected = detectClients();
|
|
50
|
+
if (config.assumeYes || process.stdin.isTTY !== true)
|
|
51
|
+
return detected;
|
|
52
|
+
CLIENTS.forEach((spec, index) => {
|
|
53
|
+
say(` ${index + 1}. [${detected.includes(spec) ? "x" : " "}] ${spec.describe}`);
|
|
54
|
+
});
|
|
55
|
+
const suggested = detected.map((spec) => CLIENTS.indexOf(spec) + 1).join(",");
|
|
56
|
+
for (let attempt = 1;; attempt++) {
|
|
57
|
+
const answer = await ask(`${brand("?")} Connect to: [${suggested}] (enter to accept, or type numbers, "all", "none") `);
|
|
58
|
+
const picked = parseChoice(answer, detected);
|
|
59
|
+
if (picked !== null)
|
|
60
|
+
return picked;
|
|
61
|
+
if (attempt === CHOICE_ATTEMPTS)
|
|
62
|
+
return detected;
|
|
63
|
+
say(fail('Type numbers from the list, "all", or "none".'));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Null when the answer names something that is not on the list. */
|
|
67
|
+
export function parseChoice(answer, detected) {
|
|
68
|
+
const text = answer.trim();
|
|
69
|
+
if (text === "")
|
|
70
|
+
return [...detected];
|
|
71
|
+
if (/^all$/i.test(text))
|
|
72
|
+
return [...CLIENTS];
|
|
73
|
+
if (/^none$/i.test(text))
|
|
74
|
+
return [];
|
|
75
|
+
const picked = new Set();
|
|
76
|
+
for (const token of text.split(/[\s,]+/)) {
|
|
77
|
+
const n = Number(token);
|
|
78
|
+
if (!Number.isInteger(n) || n < 1 || n > CLIENTS.length)
|
|
79
|
+
return null;
|
|
80
|
+
picked.add(CLIENTS[n - 1]);
|
|
81
|
+
}
|
|
82
|
+
return [...picked];
|
|
83
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -32,7 +32,7 @@ contacts and groups. Call get_status first if anything looks wrong.
|
|
|
32
32
|
|
|
33
33
|
## Identifiers
|
|
34
34
|
- chat_id — individual: \`<digits>@s.whatsapp.net\`; group: \`<id>@g.us\`. A phone
|
|
35
|
-
number in international format (+
|
|
35
|
+
number in international format (+15550100 or 15550100) also works. Pass
|
|
36
36
|
ids back exactly as a tool returned them.
|
|
37
37
|
- message_id — the full id from read_messages / search_messages. Needed for
|
|
38
38
|
get_message, download_media, react_to_message, edit_message, forward_message,
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every ANSI escape wazap emits lives here. Helpers return plain text when
|
|
3
|
+
* colour is off, so piped and captured output keeps the same visible words.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { sep } from "node:path";
|
|
7
|
+
const RESET = "\x1b[0m";
|
|
8
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
9
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
+
const FRAME_MS = 80;
|
|
11
|
+
/** stderr carries every human-readable line, so stderr decides colour. */
|
|
12
|
+
export function colorEnabled() {
|
|
13
|
+
const forced = process.env.FORCE_COLOR;
|
|
14
|
+
if (forced !== undefined)
|
|
15
|
+
return forced !== "0";
|
|
16
|
+
const disabled = process.env.NO_COLOR;
|
|
17
|
+
if (disabled !== undefined && disabled !== "")
|
|
18
|
+
return false;
|
|
19
|
+
return process.stderr.isTTY === true;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether to draw for a person rather than for a pipe. Deliberately not
|
|
23
|
+
* colorEnabled(): NO_COLOR at a terminal still wants the roomy layout, and
|
|
24
|
+
* FORCE_COLOR in a pipe must not reshape output something else is parsing.
|
|
25
|
+
*/
|
|
26
|
+
export function humanLayout() {
|
|
27
|
+
return process.stderr.isTTY === true;
|
|
28
|
+
}
|
|
29
|
+
function paint(code, text) {
|
|
30
|
+
return colorEnabled() ? `${code}${text}${RESET}` : text;
|
|
31
|
+
}
|
|
32
|
+
function brandCode() {
|
|
33
|
+
const rich = (process.env.TERM ?? "").includes("256color") || /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
|
|
34
|
+
return rich ? "\x1b[38;5;42m" : "\x1b[32m";
|
|
35
|
+
}
|
|
36
|
+
export function brand(text) {
|
|
37
|
+
return paint(brandCode(), text);
|
|
38
|
+
}
|
|
39
|
+
export function dim(text) {
|
|
40
|
+
return paint("\x1b[2m", text);
|
|
41
|
+
}
|
|
42
|
+
export function bold(text) {
|
|
43
|
+
return paint("\x1b[1m", text);
|
|
44
|
+
}
|
|
45
|
+
export function red(text) {
|
|
46
|
+
return paint("\x1b[31m", text);
|
|
47
|
+
}
|
|
48
|
+
export function yellow(text) {
|
|
49
|
+
return paint("\x1b[33m", text);
|
|
50
|
+
}
|
|
51
|
+
/** Anything the user is meant to type. */
|
|
52
|
+
export function cmd(text) {
|
|
53
|
+
return paint("\x1b[36m", text);
|
|
54
|
+
}
|
|
55
|
+
export function green(text) {
|
|
56
|
+
return paint("\x1b[32m", text);
|
|
57
|
+
}
|
|
58
|
+
export function ok(text) {
|
|
59
|
+
return `${green("✓")} ${text}`;
|
|
60
|
+
}
|
|
61
|
+
export function fail(text) {
|
|
62
|
+
return `${red("✗")} ${text}`;
|
|
63
|
+
}
|
|
64
|
+
export function info(text) {
|
|
65
|
+
return `${dim("–")} ${text}`;
|
|
66
|
+
}
|
|
67
|
+
export function warn(text) {
|
|
68
|
+
return `${yellow("!")} ${text}`;
|
|
69
|
+
}
|
|
70
|
+
/** A repair the user can run, under the line that reported the problem. */
|
|
71
|
+
export function fix(text) {
|
|
72
|
+
return ` ${yellow(`→ ${text}`)}`;
|
|
73
|
+
}
|
|
74
|
+
export function step(n, total, title) {
|
|
75
|
+
return `\nStep ${n} of ${total} · ${bold(title)}`;
|
|
76
|
+
}
|
|
77
|
+
function charWidth(code) {
|
|
78
|
+
if (code >= 0x0300 && code <= 0x036f)
|
|
79
|
+
return 0;
|
|
80
|
+
const wide = (code >= 0x1100 && code <= 0x115f) ||
|
|
81
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
82
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
83
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
84
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
85
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
86
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
87
|
+
(code >= 0x1f300 && code <= 0x1f64f) ||
|
|
88
|
+
(code >= 0x1f900 && code <= 0x1f9ff);
|
|
89
|
+
return wide ? 2 : 1;
|
|
90
|
+
}
|
|
91
|
+
export function width(text) {
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (const char of text)
|
|
94
|
+
total += charWidth(char.codePointAt(0));
|
|
95
|
+
return total;
|
|
96
|
+
}
|
|
97
|
+
export function box(text) {
|
|
98
|
+
const rule = "─".repeat(width(text) + 2);
|
|
99
|
+
return [` ╭${rule}╮`, ` │ ${text} │`, ` ╰${rule}╯`].join("\n");
|
|
100
|
+
}
|
|
101
|
+
function nextLine(action, tail) {
|
|
102
|
+
return humanLayout() ? `Next ${action}${tail}` : `Next: ${action}${tail}`;
|
|
103
|
+
}
|
|
104
|
+
/** A command to type. */
|
|
105
|
+
export function next(command, note) {
|
|
106
|
+
return nextLine(cmd(command), note === undefined ? "" : ` ${dim(note)}`);
|
|
107
|
+
}
|
|
108
|
+
/** An instruction to follow. Not cyan: cyan means "type this". */
|
|
109
|
+
export function nextHint(instruction) {
|
|
110
|
+
return nextLine(bold(instruction), "");
|
|
111
|
+
}
|
|
112
|
+
export function tilde(path) {
|
|
113
|
+
const home = homedir();
|
|
114
|
+
if (home === "")
|
|
115
|
+
return path;
|
|
116
|
+
if (path === home)
|
|
117
|
+
return "~";
|
|
118
|
+
return path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path;
|
|
119
|
+
}
|
|
120
|
+
/** Home-relative for a human; pipes and tests get the path they can act on. */
|
|
121
|
+
export function shortPath(path) {
|
|
122
|
+
return humanLayout() ? tilde(path) : path;
|
|
123
|
+
}
|
|
124
|
+
/** All but the first three digits, so a screenshot cannot leak the number. */
|
|
125
|
+
export function maskNumber(digits) {
|
|
126
|
+
const kept = digits.slice(0, 3);
|
|
127
|
+
const body = kept + "x".repeat(Math.max(0, digits.length - kept.length));
|
|
128
|
+
const groups = (body.slice(2).match(/.{1,3}/g) ?? []).join(" ");
|
|
129
|
+
return `+${body.slice(0, 2)}${groups === "" ? "" : ` ${groups}`}`;
|
|
130
|
+
}
|
|
131
|
+
/** A wrapped line would outlive the erase, which clears one physical line. */
|
|
132
|
+
function clamp(text) {
|
|
133
|
+
const room = (process.stderr.columns ?? 80) - 2;
|
|
134
|
+
if (width(text) <= room)
|
|
135
|
+
return text;
|
|
136
|
+
let out = "";
|
|
137
|
+
let used = 0;
|
|
138
|
+
for (const char of text) {
|
|
139
|
+
const size = charWidth(char.codePointAt(0));
|
|
140
|
+
if (used + size > room - 1)
|
|
141
|
+
break;
|
|
142
|
+
out += char;
|
|
143
|
+
used += size;
|
|
144
|
+
}
|
|
145
|
+
return `${out}…`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Animated only at a terminal: a `\r` rewrite is noise in a pipe, so there the
|
|
149
|
+
* text is printed once and the final line replaces nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function spinner(text) {
|
|
152
|
+
if (process.stderr.isTTY !== true) {
|
|
153
|
+
process.stderr.write(`${text}\n`);
|
|
154
|
+
return {
|
|
155
|
+
update: () => { },
|
|
156
|
+
stop: (final) => {
|
|
157
|
+
if (final !== undefined)
|
|
158
|
+
process.stderr.write(`${final}\n`);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let current = text;
|
|
163
|
+
let frame = 0;
|
|
164
|
+
const render = () => {
|
|
165
|
+
process.stderr.write(`${CLEAR_LINE}${brand(FRAMES[frame % FRAMES.length])} ${clamp(current)}`);
|
|
166
|
+
};
|
|
167
|
+
render();
|
|
168
|
+
const timer = setInterval(() => {
|
|
169
|
+
frame++;
|
|
170
|
+
render();
|
|
171
|
+
}, FRAME_MS);
|
|
172
|
+
timer.unref();
|
|
173
|
+
return {
|
|
174
|
+
update: (next) => {
|
|
175
|
+
current = next;
|
|
176
|
+
render();
|
|
177
|
+
},
|
|
178
|
+
stop: (final) => {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
process.stderr.write(CLEAR_LINE);
|
|
181
|
+
if (final !== undefined)
|
|
182
|
+
process.stderr.write(`${final}\n`);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/dist/whatsapp.js
CHANGED
|
@@ -165,6 +165,7 @@ export class WhatsAppService {
|
|
|
165
165
|
account = null;
|
|
166
166
|
lastInboundAt = null;
|
|
167
167
|
initialSyncDone = false;
|
|
168
|
+
historyReceived = false;
|
|
168
169
|
syncDeadline = null;
|
|
169
170
|
syncWaiters = [];
|
|
170
171
|
historyWaiters = [];
|
|
@@ -231,6 +232,13 @@ export class WhatsAppService {
|
|
|
231
232
|
await this.flushStore();
|
|
232
233
|
this.teardownSocket();
|
|
233
234
|
}
|
|
235
|
+
/** True once WhatsApp has delivered at least one history-sync batch. */
|
|
236
|
+
hasHistory() {
|
|
237
|
+
return this.historyReceived;
|
|
238
|
+
}
|
|
239
|
+
storeCounts() {
|
|
240
|
+
return { chats: this.store.chats.size, contacts: this.store.contacts.size, messages: this.store.messages.size };
|
|
241
|
+
}
|
|
234
242
|
getStatus() {
|
|
235
243
|
const info = {
|
|
236
244
|
status: this.status,
|
|
@@ -255,7 +263,7 @@ export class WhatsAppService {
|
|
|
255
263
|
return this.guarded(async () => {
|
|
256
264
|
this.ensureConnected();
|
|
257
265
|
await this.waitForSync();
|
|
258
|
-
const chats =
|
|
266
|
+
const chats = this.knownChats()
|
|
259
267
|
.filter((chat) => this.matchesChatFilter(chat, filter))
|
|
260
268
|
.sort((a, b) => this.chatActivity(b) - this.chatActivity(a))
|
|
261
269
|
.slice(0, limit)
|
|
@@ -727,6 +735,7 @@ export class WhatsAppService {
|
|
|
727
735
|
this.ingestChat(chat);
|
|
728
736
|
const stored = this.ingestMessages(messages ?? []);
|
|
729
737
|
void this.appendHistory(stored);
|
|
738
|
+
this.historyReceived = true;
|
|
730
739
|
this.releaseHistoryWaiters();
|
|
731
740
|
if (isLatest === true || progress === 100)
|
|
732
741
|
this.markSyncDone();
|
|
@@ -1136,8 +1145,24 @@ export class WhatsAppService {
|
|
|
1136
1145
|
const last = ring && ring.length > 0 ? this.store.messages.get(ring[ring.length - 1]) : undefined;
|
|
1137
1146
|
return last ?? null;
|
|
1138
1147
|
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Every chat WhatsApp described, plus one for any chat that only ever
|
|
1150
|
+
* arrived as messages: a chat with messages must never be invisible.
|
|
1151
|
+
*/
|
|
1152
|
+
knownChats() {
|
|
1153
|
+
const chats = [...this.store.chats.values()];
|
|
1154
|
+
for (const jid of this.store.byChat.keys()) {
|
|
1155
|
+
if (!this.store.chats.has(jid))
|
|
1156
|
+
chats.push({ id: jid });
|
|
1157
|
+
}
|
|
1158
|
+
return chats;
|
|
1159
|
+
}
|
|
1139
1160
|
chatActivity(chat) {
|
|
1140
|
-
|
|
1161
|
+
const described = protoNumber(chat.conversationTimestamp);
|
|
1162
|
+
if (described !== undefined && described !== null)
|
|
1163
|
+
return described;
|
|
1164
|
+
const last = this.lastMessageOf(this.canonical(chat.id ?? ""));
|
|
1165
|
+
return last ? Math.floor(messageTimestampMs(last) / 1000) : 0;
|
|
1141
1166
|
}
|
|
1142
1167
|
matchesChatFilter(chat, filter) {
|
|
1143
1168
|
const archived = Boolean(chat.archived);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wazap-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Răzvan Girgiz",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
29
|
"README.md",
|
|
30
|
+
"AGENT.md",
|
|
30
31
|
"LICENSE"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|