wazap-mcp 0.9.3 → 0.9.6
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 +106 -9
- package/dist/auth-state.js +25 -0
- package/dist/cli.js +56 -3
- package/dist/config.js +14 -0
- package/dist/connect.js +23 -2
- package/dist/ids.js +21 -0
- package/dist/index.js +12 -2
- package/dist/messages.js +61 -4
- package/dist/skills.js +90 -0
- package/dist/tools.js +45 -5
- package/dist/whatsapp.js +328 -47
- package/package.json +8 -2
- package/skills/wazap-setup/SKILL.md +75 -0
- package/skills/whatsapp-groups/SKILL.md +47 -0
- package/skills/whatsapp-inbox/SKILL.md +40 -0
- package/skills/whatsapp-recall/SKILL.md +25 -0
- package/skills/whatsapp-send/SKILL.md +37 -0
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
|
|
@@ -25,6 +25,23 @@ npx wazap-mcp setup
|
|
|
25
25
|
That is the whole install. It links your account, finds the MCP clients
|
|
26
26
|
installed on this machine, writes their config and tells you what to restart.
|
|
27
27
|
|
|
28
|
+
### Or the path your harness prefers
|
|
29
|
+
|
|
30
|
+
| Harness | Fastest path |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Claude Code | `/plugin marketplace add razvangirgiz/wazap`, then `/plugin install wazap@wazap` |
|
|
33
|
+
| Claude Desktop | download `wazap-<version>.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases) and double-click it |
|
|
34
|
+
| Gemini CLI | `gemini extensions install https://github.com/razvangirgiz/wazap` |
|
|
35
|
+
| Cursor | the [Install in Cursor](#other-mcp-clients) badge, then `npx wazap-mcp skills install cursor` |
|
|
36
|
+
| VS Code | the [Install in VS Code](#other-mcp-clients) badge |
|
|
37
|
+
| Codex CLI | `npx wazap-mcp connect codex`, then `npx wazap-mcp skills install codex` |
|
|
38
|
+
| OpenCode | `npx wazap-mcp connect opencode`, then `npx wazap-mcp skills install opencode` |
|
|
39
|
+
| Windsurf | `npx wazap-mcp connect windsurf` |
|
|
40
|
+
| Anything else | the MCP entry `npx -y wazap-mcp` over stdio, or a [self-hosted](#self-host) URL |
|
|
41
|
+
|
|
42
|
+
Each of those registers the server. Linking the WhatsApp account is a separate,
|
|
43
|
+
one-time step in every one of them: `npx wazap-mcp login`.
|
|
44
|
+
|
|
28
45
|
Or have your agent do it. Paste this:
|
|
29
46
|
|
|
30
47
|
*Set up WhatsApp for me: run `npx wazap-mcp setup --agent` and follow what it prints.*
|
|
@@ -63,11 +80,25 @@ it would write.
|
|
|
63
80
|
| `codex` | `[mcp_servers.whatsapp]` in `~/.codex/config.toml` |
|
|
64
81
|
| `vscode` | `./.vscode/mcp.json`, for the current workspace |
|
|
65
82
|
| `gemini` | `~/.gemini/settings.json` |
|
|
83
|
+
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
|
|
84
|
+
| `opencode` | `mcp.whatsapp` in `~/.config/opencode/opencode.json` |
|
|
66
85
|
| anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>` (see [Self-host](#self-host)) |
|
|
67
86
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
### Other MCP clients
|
|
88
|
+
|
|
89
|
+
Cursor and VS Code install from a link:
|
|
90
|
+
|
|
91
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=whatsapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIndhemFwLW1jcCJdfQ)
|
|
92
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22whatsapp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D)
|
|
93
|
+
|
|
94
|
+
Both carry the same entry `connect` writes. Where a custom scheme is stripped
|
|
95
|
+
before you can click it, VS Code also takes
|
|
96
|
+
[the https form](https://insiders.vscode.dev/redirect/mcp/install?name=whatsapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D).
|
|
97
|
+
`node scripts/badges.mjs` reprints all three.
|
|
98
|
+
|
|
99
|
+
Any other MCP client works the same way: the command is `npx -y wazap-mcp`, the
|
|
100
|
+
transport is stdio. Tell the agent to call `learn` first — it returns the id
|
|
101
|
+
formats, the workflows and every error code with what to do about it.
|
|
71
102
|
|
|
72
103
|
<details>
|
|
73
104
|
<summary>The raw entries, for editing by hand</summary>
|
|
@@ -83,8 +114,9 @@ workflows and every error code with what to do about it.
|
|
|
83
114
|
}
|
|
84
115
|
```
|
|
85
116
|
|
|
86
|
-
Claude Desktop, Cursor
|
|
87
|
-
`servers` and wants a `"type": "stdio"` alongside `command`. Codex CLI
|
|
117
|
+
Claude Desktop, Cursor, Gemini CLI and Windsurf take exactly that. VS Code nests
|
|
118
|
+
it under `servers` and wants a `"type": "stdio"` alongside `command`. Codex CLI
|
|
119
|
+
is TOML:
|
|
88
120
|
|
|
89
121
|
```toml
|
|
90
122
|
[mcp_servers.whatsapp]
|
|
@@ -92,22 +124,68 @@ command = "npx"
|
|
|
92
124
|
args = ["-y", "wazap-mcp"]
|
|
93
125
|
```
|
|
94
126
|
|
|
127
|
+
OpenCode takes the command and its arguments as one array, under `mcp`:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"mcp": {
|
|
132
|
+
"whatsapp": { "type": "local", "command": ["npx", "-y", "wazap-mcp"] }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
95
137
|
</details>
|
|
96
138
|
|
|
97
139
|
The `skills/` folder follows the [Agent Skills](https://agentskills.io) format, so Codex, Cursor and other skill-aware agents can load the same five skills.
|
|
98
140
|
|
|
141
|
+
### Gemini CLI
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
gemini extensions install https://github.com/razvangirgiz/wazap
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
That reads `gemini-extension.json` at the repo root, so it registers the MCP
|
|
148
|
+
server and loads `GEMINI.md` — the five skills below, concatenated, because the
|
|
149
|
+
Gemini CLI takes one context file per extension rather than a skills directory.
|
|
150
|
+
`wazap connect gemini` writes the server alone, without the context.
|
|
151
|
+
|
|
152
|
+
`GEMINI.md` is generated: `npm run context:build` rebuilds it from
|
|
153
|
+
`skills/*/SKILL.md`, and a test fails if the two have drifted, so a workflow is
|
|
154
|
+
only ever edited in its skill.
|
|
155
|
+
|
|
156
|
+
### Claude Desktop, without a terminal
|
|
157
|
+
|
|
158
|
+
Download `wazap-<version>.mcpb` from [Releases](https://github.com/razvangirgiz/wazap/releases)
|
|
159
|
+
and double-click it. Claude Desktop installs the server, its Node dependencies
|
|
160
|
+
and the icon, and shows two settings: **Read-only**, ticked, and **Data
|
|
161
|
+
directory**, empty. `wazap connect claude-desktop` does the same job by editing
|
|
162
|
+
`claude_desktop_config.json`, and needs `npx` at launch; the bundle does not.
|
|
163
|
+
|
|
164
|
+
Linking the account still needs a terminal once: `npx wazap-mcp login`. The
|
|
165
|
+
bundle reads the session that login writes to `~/.wazap`.
|
|
166
|
+
|
|
167
|
+
Untick **Read-only** to let Claude send. It ships ticked because a bundle that
|
|
168
|
+
can message people from your number before you have said so is the wrong
|
|
169
|
+
default, and because the setting cannot be left unanswered: the manifest format
|
|
170
|
+
has no way to omit an argument, so the box you see is the answer the server gets.
|
|
171
|
+
|
|
172
|
+
Build it yourself with `npm run bundle:mcpb`, which stages `dist/`, the
|
|
173
|
+
manifest, the icon and a fresh production `node_modules`, then packs them with
|
|
174
|
+
[`@anthropic-ai/mcpb`](https://github.com/modelcontextprotocol/mcpb).
|
|
175
|
+
|
|
99
176
|
## Tools
|
|
100
177
|
|
|
101
178
|
| Tool | Kind | What it does |
|
|
102
179
|
| --- | --- | --- |
|
|
103
180
|
| `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. |
|
|
181
|
+
| `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
|
|
105
182
|
| `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
|
|
106
183
|
| `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. |
|
|
184
|
+
| `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
185
|
| `search_messages` | read | Text search across the locally held messages. |
|
|
109
186
|
| `get_message` | read | One message in full, with its quoted message and reactions. |
|
|
110
187
|
| `search_contacts` | read | Find contacts by name or number. |
|
|
188
|
+
| `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |
|
|
111
189
|
| `get_contact` | read | Name, number, about text, profile picture. |
|
|
112
190
|
| `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |
|
|
113
191
|
| `download_media` | read | Save an attachment to disk; small images also come back inline. |
|
|
@@ -147,7 +225,22 @@ Install everything (server and skills) as a Claude Code plugin:
|
|
|
147
225
|
/plugin install wazap@wazap
|
|
148
226
|
```
|
|
149
227
|
|
|
150
|
-
|
|
228
|
+
Every other harness gets them with one command:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npx wazap-mcp skills install codex # or cursor, opencode, agents
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| Harness | Where the five directories land |
|
|
235
|
+
| --- | --- |
|
|
236
|
+
| `claude-code` | nowhere — the plugin above already carries them |
|
|
237
|
+
| `codex` | `~/.agents/skills/`, the directory Codex documents for user skills. Cursor and OpenCode read it too |
|
|
238
|
+
| `cursor` | `~/.cursor/skills/` |
|
|
239
|
+
| `opencode` | `~/.config/opencode/skills/` |
|
|
240
|
+
| `agents` | `./.agents/skills/`, in the current project, for anything that reads the cross-tool convention |
|
|
241
|
+
|
|
242
|
+
Re-running overwrites, so an upgrade is the same command. `--dry-run` lists
|
|
243
|
+
what it would copy.
|
|
151
244
|
|
|
152
245
|
## Errors
|
|
153
246
|
|
|
@@ -307,6 +400,10 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
|
307
400
|
- **`@lid` ids.** Newer accounts are addressed by a privacy id rather than a
|
|
308
401
|
phone number. wazap translates them back to phone numbers when it has learned
|
|
309
402
|
the mapping, and passes the `@lid` through when it has not.
|
|
403
|
+
- **Names come from the phone's address book.** WhatsApp delivers it as an app
|
|
404
|
+
state sync, and only to a connection asking for it from scratch. If contacts
|
|
405
|
+
read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
|
|
406
|
+
again with the `sync_contacts` tool or `wazap contacts resync`.
|
|
310
407
|
- **Your phone must stay reachable.** A linked device stops receiving once the
|
|
311
408
|
phone has been offline long enough; `get_status` says so in `hint`.
|
|
312
409
|
|
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/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
5
5
|
import makeWASocket, { DisconnectReason, } from "baileys";
|
|
6
6
|
import qrcode from "qrcode";
|
|
7
7
|
import qrcodeTerminal from "qrcode-terminal";
|
|
8
|
-
import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
8
|
+
import { clearAuth, readLinkedAccount, useAtomicAuthState, withoutAppStateSync, } from "./auth-state.js";
|
|
9
9
|
import { banner } from "./banner.js";
|
|
10
10
|
import { runBridge } from "./bridge.js";
|
|
11
11
|
import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
|
|
@@ -20,7 +20,7 @@ import { formatAge } from "./messages.js";
|
|
|
20
20
|
import { RateLimiter } from "./ratelimit.js";
|
|
21
21
|
import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
|
|
22
22
|
import { applyWrites } from "./settings.js";
|
|
23
|
-
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";
|
|
24
24
|
import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
|
|
25
25
|
const LOGIN_TIMEOUT_MS = 120_000;
|
|
26
26
|
const LIVE_TIMEOUT_MS = 15_000;
|
|
@@ -194,6 +194,53 @@ async function runLiveProbe(config) {
|
|
|
194
194
|
releaseLock(p.lockFile);
|
|
195
195
|
}
|
|
196
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
|
+
}
|
|
197
244
|
/** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
|
|
198
245
|
export async function runGreet(config) {
|
|
199
246
|
say(banner());
|
|
@@ -580,10 +627,16 @@ async function linkSession(authDir, opts) {
|
|
|
580
627
|
if (expired)
|
|
581
628
|
throw timedOut();
|
|
582
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.
|
|
583
635
|
const sock = makeWASocket({
|
|
584
|
-
auth: state,
|
|
636
|
+
auth: withoutAppStateSync(state),
|
|
585
637
|
browser: WA_BROWSER,
|
|
586
638
|
markOnlineOnConnect: false,
|
|
639
|
+
shouldSyncHistoryMessage: () => false,
|
|
587
640
|
logger: SILENT_LOGGER,
|
|
588
641
|
});
|
|
589
642
|
current = sock;
|
package/dist/config.js
CHANGED
|
@@ -29,6 +29,8 @@ const COMMAND_ARGS = {
|
|
|
29
29
|
logout: [0],
|
|
30
30
|
connect: [1],
|
|
31
31
|
config: [0, 2],
|
|
32
|
+
contacts: [1],
|
|
33
|
+
skills: [2],
|
|
32
34
|
};
|
|
33
35
|
const COMMANDS = Object.keys(COMMAND_ARGS);
|
|
34
36
|
export function defaultDataDir() {
|
|
@@ -43,6 +45,17 @@ function asInt(value, fallback) {
|
|
|
43
45
|
const n = Number.parseInt((value ?? "").trim(), 10);
|
|
44
46
|
return Number.isFinite(n) ? n : fallback;
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A user_config slot the person never filled in reaches us as the literal
|
|
50
|
+
* `${user_config.data_dir}`: the Claude Desktop bundle substitutes what it has
|
|
51
|
+
* and leaves the rest alone. An unanswered question is not a data directory.
|
|
52
|
+
*/
|
|
53
|
+
function dropUnfilledTemplates() {
|
|
54
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
55
|
+
if (key.startsWith("WAZAP_") && /^\$\{[^}]*\}$/.test(value ?? ""))
|
|
56
|
+
delete process.env[key];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
46
59
|
/**
|
|
47
60
|
* A human at a terminal running bare `wazap` wants to see where they stand, not
|
|
48
61
|
* a silent MCP server on stdin. Everything else serves, including `wazap serve`.
|
|
@@ -87,6 +100,7 @@ export function parseCli(argv = process.argv.slice(2)) {
|
|
|
87
100
|
return { kind: "help" };
|
|
88
101
|
if (values.version)
|
|
89
102
|
return { kind: "version" };
|
|
103
|
+
dropUnfilledTemplates();
|
|
90
104
|
const [first, ...args] = positionals;
|
|
91
105
|
if (first !== undefined && !COMMANDS.includes(first)) {
|
|
92
106
|
throw new WazapError("INVALID_ID", `Unknown command "${first}".`, "Run `wazap --help`");
|
package/dist/connect.js
CHANGED
|
@@ -58,7 +58,7 @@ export const CLIENTS = [
|
|
|
58
58
|
file: () => join(process.cwd(), ".vscode", "mcp.json"),
|
|
59
59
|
format: "json",
|
|
60
60
|
keyPath: ["servers", "whatsapp"],
|
|
61
|
-
|
|
61
|
+
value: (entry) => ({ type: "stdio", ...entry }),
|
|
62
62
|
next: "Written to ./.vscode/mcp.json for this workspace. Reload the VS Code window.",
|
|
63
63
|
detect: (probe) => probe.onPath("code"),
|
|
64
64
|
},
|
|
@@ -71,6 +71,27 @@ export const CLIENTS = [
|
|
|
71
71
|
next: "Restart the Gemini CLI.",
|
|
72
72
|
detect: (probe) => probe.exists(join(homedir(), ".gemini")),
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "windsurf",
|
|
76
|
+
describe: "Windsurf",
|
|
77
|
+
file: () => join(homedir(), ".codeium", "windsurf", "mcp_config.json"),
|
|
78
|
+
format: "json",
|
|
79
|
+
keyPath: ["mcpServers", "whatsapp"],
|
|
80
|
+
next: "Refresh the MCP servers in Windsurf's Cascade panel.",
|
|
81
|
+
detect: (probe) => probe.exists(join(homedir(), ".codeium", "windsurf")),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "opencode",
|
|
85
|
+
describe: "OpenCode",
|
|
86
|
+
file: () => join(homedir(), ".config", "opencode", "opencode.json"),
|
|
87
|
+
format: "json",
|
|
88
|
+
keyPath: ["mcp", "whatsapp"],
|
|
89
|
+
// OpenCode takes one array where the others take a command and its args,
|
|
90
|
+
// and its schema refuses anything else under the key.
|
|
91
|
+
value: (entry) => ({ type: "local", command: [entry.command, ...entry.args] }),
|
|
92
|
+
next: "Restart OpenCode.",
|
|
93
|
+
detect: (probe) => probe.exists(join(homedir(), ".config", "opencode")),
|
|
94
|
+
},
|
|
74
95
|
];
|
|
75
96
|
export const CLIENT_NAMES = CLIENTS.map((client) => client.name).join(", ");
|
|
76
97
|
const OTHER_CLIENTS = CLIENTS.slice(1)
|
|
@@ -173,7 +194,7 @@ function writeJsonEntry(spec, entry, dryRun) {
|
|
|
173
194
|
}
|
|
174
195
|
doc = parsed;
|
|
175
196
|
}
|
|
176
|
-
const value =
|
|
197
|
+
const value = spec.value?.(entry) ?? { command: entry.command, args: entry.args };
|
|
177
198
|
setIn(doc, spec.keyPath, value);
|
|
178
199
|
// Indent 1 collapsed to one line: short enough to read, still spaced like JSON.
|
|
179
200
|
const shown = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ");
|
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,8 +1,9 @@
|
|
|
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
|
+
import { SKILL_TARGET_NAMES, runSkills } from "./skills.js";
|
|
6
7
|
import { runSetup } from "./setup.js";
|
|
7
8
|
import { runConfig } from "./settings.js";
|
|
8
9
|
import { WazapError } from "./errors.js";
|
|
@@ -15,11 +16,14 @@ Usage:
|
|
|
15
16
|
wazap login [--phone +15550100] [--code] Link a WhatsApp account (QR by default)
|
|
16
17
|
wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
|
|
17
18
|
wazap connect <client> [--dry-run] Register wazap with an MCP client
|
|
19
|
+
wazap skills install <harness> [--dry-run] Copy the five skills into a harness
|
|
18
20
|
wazap config [writes on|off] Show the effective settings, or allow/refuse writes
|
|
21
|
+
wazap contacts resync Fetch the phone's address book from WhatsApp again
|
|
19
22
|
wazap status [--live] [--json] Check the install, the session and the server
|
|
20
23
|
wazap logout Unlink and delete local credentials
|
|
21
24
|
|
|
22
25
|
Clients for wazap connect: ${CLIENT_NAMES}.
|
|
26
|
+
Harnesses for wazap skills install: ${SKILL_TARGET_NAMES}.
|
|
23
27
|
|
|
24
28
|
Options:
|
|
25
29
|
--data-dir <path> Where wazap keeps its data (default ~/.wazap, or $WAZAP_DATA_DIR)
|
|
@@ -31,7 +35,7 @@ Options:
|
|
|
31
35
|
--phone <number> Your number in international format; implies --code
|
|
32
36
|
--agent With setup: print the procedure for an AI agent on stdout, then exit
|
|
33
37
|
--client <name> With setup: connect this client instead of the detected ones (repeatable)
|
|
34
|
-
--dry-run With connect: print what would be written, and write nothing
|
|
38
|
+
--dry-run With connect or skills install: print what would be written, and write nothing
|
|
35
39
|
--live With status: reach WhatsApp for real, then close the connection
|
|
36
40
|
--json With status: print the whole report as one JSON object on stdout
|
|
37
41
|
--writes Allow the agent to write, without login asking
|
|
@@ -72,9 +76,15 @@ async function main() {
|
|
|
72
76
|
case "connect":
|
|
73
77
|
runConnect(config);
|
|
74
78
|
return;
|
|
79
|
+
case "skills":
|
|
80
|
+
runSkills(config);
|
|
81
|
+
return;
|
|
75
82
|
case "config":
|
|
76
83
|
runConfig(config);
|
|
77
84
|
return;
|
|
85
|
+
case "contacts":
|
|
86
|
+
await runContacts(config);
|
|
87
|
+
return;
|
|
78
88
|
case "status":
|
|
79
89
|
await runStatus(config);
|
|
80
90
|
return;
|
package/dist/messages.js
CHANGED
|
@@ -14,7 +14,8 @@ export function messageTimestampMs(raw) {
|
|
|
14
14
|
const seconds = protoNumber(raw.messageTimestamp);
|
|
15
15
|
return seconds === undefined ? Date.now() : seconds * 1000;
|
|
16
16
|
}
|
|
17
|
-
/** Stable across restarts
|
|
17
|
+
/** Stable across restarts. Not stable if the chat's own jid is later remapped
|
|
18
|
+
* from a LID to a phone number, which is why wazap does not remap chat jids. */
|
|
18
19
|
export function messageIdFor(key, chatId) {
|
|
19
20
|
return `${key.fromMe ? "true" : "false"}_${chatId}_${key.id ?? ""}`;
|
|
20
21
|
}
|
|
@@ -50,7 +51,28 @@ const PROTOCOL = {
|
|
|
50
51
|
tag: (m) => (isRevoke(m) ? DELETED_TEXT : SYSTEM_TEXT),
|
|
51
52
|
};
|
|
52
53
|
const SYSTEM = { type: "system", tag: SYSTEM_TEXT };
|
|
53
|
-
|
|
54
|
+
/** Names the payload, so a bug report says which one to add. */
|
|
55
|
+
const UNKNOWN = {
|
|
56
|
+
type: "unknown",
|
|
57
|
+
tag: (m) => {
|
|
58
|
+
const key = getContentType(m) ?? Object.keys(m).find((name) => m[name] != null);
|
|
59
|
+
return key ? `[unsupported: ${key}]` : "[unsupported message]";
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Payloads WhatsApp exchanges with its own clients; no person ever sent one. */
|
|
63
|
+
const CONTROL_KEYS = ["messageContextInfo", "senderKeyDistributionMessage"];
|
|
64
|
+
/**
|
|
65
|
+
* The protocol messages that report something a person did. Baileys enumerates
|
|
66
|
+
* exactly these as cross-user in Utils/process-message.js — every other type is
|
|
67
|
+
* one device talking to another. MESSAGE_EDIT is cross-user too, but wazap
|
|
68
|
+
* applies the edit to the message it edits, so its envelope is not a second
|
|
69
|
+
* message to show.
|
|
70
|
+
*/
|
|
71
|
+
const REPORTABLE_PROTOCOL_TYPES = new Set([
|
|
72
|
+
proto.Message.ProtocolMessage.Type.REVOKE,
|
|
73
|
+
proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
|
|
74
|
+
proto.Message.ProtocolMessage.Type.GROUP_MEMBER_LABEL_CHANGE,
|
|
75
|
+
]);
|
|
54
76
|
/**
|
|
55
77
|
* One table drives both messageType and messageText, so the reported type and
|
|
56
78
|
* the placeholder can never disagree.
|
|
@@ -126,8 +148,13 @@ function ruleFor(content) {
|
|
|
126
148
|
const rule = key ? RULES[key] : undefined;
|
|
127
149
|
if (rule)
|
|
128
150
|
return { rule, content };
|
|
129
|
-
|
|
151
|
+
// Only when the control keys are all there is. A payload wazap does not model
|
|
152
|
+
// yet usually carries messageContextInfo alongside it, and calling that a
|
|
153
|
+
// system message would hide someone's event, album or order behind
|
|
154
|
+
// "[system message]" and then out of the digest.
|
|
155
|
+
if (key === undefined && (content.messageContextInfo || content.senderKeyDistributionMessage)) {
|
|
130
156
|
return { rule: SYSTEM, content };
|
|
157
|
+
}
|
|
131
158
|
return { rule: UNKNOWN, content };
|
|
132
159
|
}
|
|
133
160
|
function stubKind(raw) {
|
|
@@ -141,6 +168,36 @@ function stubKind(raw) {
|
|
|
141
168
|
function resolve(value, content) {
|
|
142
169
|
return typeof value === "function" ? value(content) : value;
|
|
143
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* True for the machinery WhatsApp runs between devices: history-sync notices,
|
|
173
|
+
* app-state and peer-data responses, sender-key distribution, bare context
|
|
174
|
+
* info. They carry nothing a person did, so they are dropped rather than shown.
|
|
175
|
+
*/
|
|
176
|
+
export function isControlMessage(raw) {
|
|
177
|
+
if (isStubEvent(raw))
|
|
178
|
+
return false;
|
|
179
|
+
const content = unwrapEnvelopes(raw.message);
|
|
180
|
+
if (!content)
|
|
181
|
+
return true;
|
|
182
|
+
const key = getContentType(content);
|
|
183
|
+
if (key === "protocolMessage")
|
|
184
|
+
return !REPORTABLE_PROTOCOL_TYPES.has(content.protocolMessage?.type ?? -1);
|
|
185
|
+
if (key !== undefined)
|
|
186
|
+
return false;
|
|
187
|
+
// getContentType ignores the control keys, so reaching here means the payload
|
|
188
|
+
// is either nothing at all or nothing but control keys.
|
|
189
|
+
const present = Object.keys(content).filter((name) => content[name] != null);
|
|
190
|
+
return present.length === 0 || present.every((name) => CONTROL_KEYS.includes(name));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A message whose whole content is a stub type: WhatsApp's own notices about
|
|
194
|
+
* device linking, group membership and encryption. Baileys builds these with no
|
|
195
|
+
* `message` field at all, so they have to be recognised before the usual
|
|
196
|
+
* "no content, nothing to store" guard throws them away.
|
|
197
|
+
*/
|
|
198
|
+
export function isStubEvent(raw) {
|
|
199
|
+
return stubKind(raw) !== undefined;
|
|
200
|
+
}
|
|
144
201
|
export function messageType(raw) {
|
|
145
202
|
const stub = stubKind(raw);
|
|
146
203
|
if (stub === "deleted")
|
|
@@ -244,7 +301,7 @@ export function buildMessageView(raw, ctx) {
|
|
|
244
301
|
from_me: Boolean(raw.key.fromMe),
|
|
245
302
|
sender: {
|
|
246
303
|
id: sender,
|
|
247
|
-
name: ctx.nameFor(sender)
|
|
304
|
+
name: ctx.nameFor(sender),
|
|
248
305
|
...(phoneOf(sender) ? { phone: phoneOf(sender) } : {}),
|
|
249
306
|
},
|
|
250
307
|
type: messageType(raw),
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cpSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { WazapError } from "./errors.js";
|
|
6
|
+
import { say } from "./logger.js";
|
|
7
|
+
import { info, next, nextHint, ok, shortPath } from "./ui.js";
|
|
8
|
+
/**
|
|
9
|
+
* Each path is the one that harness's own docs name today. Codex is the
|
|
10
|
+
* one that moved: `~/.codex/skills` still loads, but its docs call that
|
|
11
|
+
* location deprecated and put user skills in `~/.agents/skills`, which Cursor
|
|
12
|
+
* and OpenCode read too.
|
|
13
|
+
*/
|
|
14
|
+
export const SKILL_TARGETS = [
|
|
15
|
+
{
|
|
16
|
+
name: "claude-code",
|
|
17
|
+
describe: "Claude Code",
|
|
18
|
+
dir: null,
|
|
19
|
+
next: "/plugin marketplace add razvangirgiz/wazap",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "codex",
|
|
23
|
+
describe: "Codex CLI",
|
|
24
|
+
dir: () => join(homedir(), ".agents", "skills"),
|
|
25
|
+
next: "Restart Codex. Cursor and OpenCode read this directory too.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "cursor",
|
|
29
|
+
describe: "Cursor",
|
|
30
|
+
dir: () => join(homedir(), ".cursor", "skills"),
|
|
31
|
+
next: "Reload the Cursor window.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "opencode",
|
|
35
|
+
describe: "OpenCode",
|
|
36
|
+
dir: () => join(homedir(), ".config", "opencode", "skills"),
|
|
37
|
+
next: "Restart OpenCode.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "agents",
|
|
41
|
+
describe: "This project, any agent",
|
|
42
|
+
dir: () => join(process.cwd(), ".agents", "skills"),
|
|
43
|
+
next: "Commit .agents/skills to share them with whoever clones this repo.",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
export const SKILL_TARGET_NAMES = SKILL_TARGETS.map((target) => target.name).join(", ");
|
|
47
|
+
/** The `skills/` folder shipped in the npm package, next to `dist/`. */
|
|
48
|
+
function packagedSkills() {
|
|
49
|
+
return fileURLToPath(new URL("../skills", import.meta.url));
|
|
50
|
+
}
|
|
51
|
+
function skillNames() {
|
|
52
|
+
try {
|
|
53
|
+
return readdirSync(packagedSkills(), { withFileTypes: true })
|
|
54
|
+
.filter((entry) => entry.isDirectory())
|
|
55
|
+
.map((entry) => entry.name)
|
|
56
|
+
.sort();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new WazapError("FILE_NOT_FOUND", "This wazap install ships no skills/ directory.", "Upgrade with `npm i -g wazap-mcp@latest`, or run this from a checkout.");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function findSkillTarget(name) {
|
|
63
|
+
const target = SKILL_TARGETS.find((candidate) => candidate.name === name);
|
|
64
|
+
if (!target) {
|
|
65
|
+
throw new WazapError("INVALID_ID", `Unknown harness "${name}".`, `Pick one of: ${SKILL_TARGET_NAMES}`);
|
|
66
|
+
}
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
export function runSkills(config) {
|
|
70
|
+
if (config.args[0] !== "install") {
|
|
71
|
+
throw new WazapError("INVALID_ID", `Unknown skills command "${config.args[0]}".`, "Run `wazap skills install <harness>`");
|
|
72
|
+
}
|
|
73
|
+
const target = findSkillTarget(config.args[1] ?? "");
|
|
74
|
+
if (target.dir === null) {
|
|
75
|
+
say(info(`${target.describe} loads these skills from the wazap plugin, along with the MCP server.`));
|
|
76
|
+
say(next(target.next));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const dir = target.dir();
|
|
80
|
+
const names = skillNames();
|
|
81
|
+
say(info(`${target.describe} · ${config.dryRun ? "would copy into" : "copying into"} ${shortPath(dir)}`));
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
// Overwriting is the point: this is how an upgrade reaches an already
|
|
84
|
+
// installed harness, and re-running it must land in the same place.
|
|
85
|
+
if (!config.dryRun)
|
|
86
|
+
cpSync(join(packagedSkills(), name), join(dir, name), { recursive: true, force: true });
|
|
87
|
+
say(` ${ok(name)}`);
|
|
88
|
+
}
|
|
89
|
+
say(nextHint(target.next));
|
|
90
|
+
}
|