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 CHANGED
@@ -8,7 +8,7 @@
8
8
  ```
9
9
 
10
10
  **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
11
- chats, messages, media, contacts, groups — behind 22 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 23 tools any MCP client can
12
12
  call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
13
13
 
14
14
  Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
@@ -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
- Any MCP client works the same way: the command is `npx -y wazap-mcp`, the transport
69
- is stdio. Tell the agent to call `learn` first — it returns the id formats, the
70
- workflows and every error code with what to do about it.
87
+ ### Other MCP clients
88
+
89
+ Cursor and VS Code install from a link:
90
+
91
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.png)](cursor://anysphere.cursor-deeplink/mcp/install?name=whatsapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIndhemFwLW1jcCJdfQ)
92
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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 and Gemini CLI take exactly that. VS Code nests it under
87
- `servers` and wants a `"type": "stdio"` alongside `command`. Codex CLI is TOML:
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
- Or copy `skills/<name>/` into any skills directory your agent reads.
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
 
@@ -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
- extra: { type: "stdio" },
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 = { ...spec.extra, command: entry.command, args: entry.args };
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, and stable under lid→pn remapping of the chat. */
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
- const UNKNOWN = { type: "unknown", tag: "[unsupported message]" };
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
- if (content.messageContextInfo || content.senderKeyDistributionMessage)
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) || raw.pushName || phoneOf(sender) || 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
+ }