wazap-mcp 0.9.5 → 0.9.7

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
@@ -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,10 +124,55 @@ 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 |
@@ -103,8 +180,8 @@ The `skills/` folder follows the [Agent Skills](https://agentskills.io) format,
103
180
  | `learn` | read | The guide to every tool, id format and error code. Call it first. |
104
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
- | `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. `include_system` adds WhatsApp's own notices. |
183
+ | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`. |
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, `types` narrows to one or more message types. |
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. |
@@ -148,7 +225,22 @@ Install everything (server and skills) as a Claude Code plugin:
148
225
  /plugin install wazap@wazap
149
226
  ```
150
227
 
151
- 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.
152
244
 
153
245
  ## Errors
154
246
 
@@ -312,6 +404,12 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
312
404
  state sync, and only to a connection asking for it from scratch. If contacts
313
405
  read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
314
406
  again with the `sync_contacts` tool or `wazap contacts resync`.
407
+ - **Calls are WhatsApp calls only.** A call shows up as a message with
408
+ `type: "call"`, carrying its kind, direction, outcome and duration. WhatsApp's
409
+ own call log and the missed-call notices arrive on their own; a call that
410
+ starts and ends while wazap is running is recorded live, so calls placed or
411
+ received while it is stopped can be missing entirely. A cellular call from the
412
+ phone's dialler is never visible, on any device.
315
413
  - **Your phone must stay reachable.** A linked device stops receiving once the
316
414
  phone has been offline long enough; `get_status` says so in `hint`.
317
415
 
package/dist/calls.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Live calls. Baileys reports a call as a stream of status events and never as
3
+ * a message, so this folds that stream into one entry per call and hands back a
4
+ * synthetic WAMessage the ordinary store path can carry. Pure: no timers, no
5
+ * socket, no store, so a test can drive it by feeding events and a clock.
6
+ */
7
+ import { proto } from "baileys";
8
+ /** A ringing call nobody answered and nobody hung up: the terminal event was lost. */
9
+ const RING_TIMEOUT_MS = 2 * 60_000;
10
+ /**
11
+ * An answered call is not expired at the ring timeout, which would invent a
12
+ * two-minute duration for a conversation still going on. It is only cut loose
13
+ * once it has run longer than any real call does.
14
+ */
15
+ const ANSWERED_CAP_MS = 6 * 3_600_000;
16
+ /**
17
+ * How many settled call ids to remember. They are what makes a repeated
18
+ * terminal event store nothing twice, and the process is long-lived, so the set
19
+ * has to forget its oldest eventually rather than grow for the whole session.
20
+ */
21
+ const SETTLED_MEMORY = 500;
22
+ /** Marks a stored message as one wazap tracked itself. See `isTrackedCall`. */
23
+ const TRACKED_ID_PREFIX = "call_";
24
+ const OUTCOME_CODES = {
25
+ answered: proto.Message.CallLogMessage.CallOutcome.CONNECTED,
26
+ rejected: proto.Message.CallLogMessage.CallOutcome.REJECTED,
27
+ missed: proto.Message.CallLogMessage.CallOutcome.MISSED,
28
+ unanswered: proto.Message.CallLogMessage.CallOutcome.MISSED,
29
+ };
30
+ function noAnswer(direction) {
31
+ return direction === "outgoing" ? "unanswered" : "missed";
32
+ }
33
+ /**
34
+ * `from` arrives as a LID as often as a phone jid, and either can carry a
35
+ * device suffix, so only the user part of the two jids is comparable.
36
+ */
37
+ function samePerson(one, other) {
38
+ const user = (jid) => (jid.split("@")[0] ?? "").split(":")[0] ?? "";
39
+ const left = user(one);
40
+ return left.length > 0 && left === user(other);
41
+ }
42
+ export class CallTracker {
43
+ calls = new Map();
44
+ settled = new Set();
45
+ get pending() {
46
+ return this.calls.size;
47
+ }
48
+ /** The entry to store once the call reaches a terminal state, else null. */
49
+ observe(event, ownJid, now) {
50
+ if (!event.id || this.settled.has(event.id))
51
+ return null;
52
+ const call = this.calls.get(event.id) ?? this.begin(event, ownJid, now);
53
+ call.lastSeen = now;
54
+ switch (event.status) {
55
+ case "accept":
56
+ call.acceptedAt = now;
57
+ return null;
58
+ case "reject":
59
+ return this.finish(call, "rejected");
60
+ case "timeout":
61
+ return this.finish(call, noAnswer(call.direction));
62
+ case "terminate":
63
+ return call.acceptedAt === undefined
64
+ ? this.finish(call, noAnswer(call.direction))
65
+ : this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000));
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+ /** Entries for calls whose terminal event never arrived. */
71
+ expire(now) {
72
+ const done = [];
73
+ for (const call of [...this.calls.values()]) {
74
+ if (call.acceptedAt === undefined) {
75
+ if (now - call.lastSeen >= RING_TIMEOUT_MS)
76
+ done.push(this.finish(call, noAnswer(call.direction)));
77
+ }
78
+ else if (now - call.acceptedAt >= ANSWERED_CAP_MS) {
79
+ done.push(this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000)));
80
+ }
81
+ }
82
+ return done;
83
+ }
84
+ /**
85
+ * An event for an unknown call-id starts a pending call from whatever it
86
+ * carries, so a restart in the middle of one still records something. Only
87
+ * the offer names isVideo and the group, which is why baileys replays them
88
+ * from its own cache onto the later events of the same call.
89
+ */
90
+ begin(event, ownJid, now) {
91
+ const offered = event.date instanceof Date ? event.date.getTime() : Number.NaN;
92
+ const chat = (event.isGroup ? (event.groupJid ?? event.chatId) : event.chatId) || event.from;
93
+ const call = {
94
+ callId: event.id,
95
+ chatId: chat,
96
+ at: Number.isFinite(offered) ? offered : now,
97
+ kind: event.isVideo ? "video" : "voice",
98
+ direction: samePerson(event.from, ownJid) ? "outgoing" : "incoming",
99
+ lastSeen: now,
100
+ };
101
+ this.calls.set(event.id, call);
102
+ return call;
103
+ }
104
+ finish(call, outcome, durationSeconds) {
105
+ this.calls.delete(call.callId);
106
+ this.settled.add(call.callId);
107
+ if (this.settled.size > SETTLED_MEMORY) {
108
+ const oldest = this.settled.values().next().value;
109
+ if (oldest !== undefined)
110
+ this.settled.delete(oldest);
111
+ }
112
+ return {
113
+ callId: call.callId,
114
+ chatId: call.chatId,
115
+ at: call.at,
116
+ kind: call.kind,
117
+ direction: call.direction,
118
+ outcome,
119
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
120
+ };
121
+ }
122
+ }
123
+ /**
124
+ * True for a message this tracker built. The tracker emits one entry per call
125
+ * id, so two of these are always two different calls however close together
126
+ * they fall, which is the one thing a dedupe by timestamp cannot know.
127
+ */
128
+ export function isTrackedCall(raw) {
129
+ return (raw.key?.id ?? "").startsWith(TRACKED_ID_PREFIX);
130
+ }
131
+ /**
132
+ * The entry as WhatsApp would have logged it, so snapshot, history JSONL, views
133
+ * and list_chats all carry a live call with no machinery of their own. The
134
+ * fields have to survive an encode/decode round trip, because that is what
135
+ * persistence does to it.
136
+ */
137
+ export function callMessage(entry) {
138
+ return {
139
+ key: {
140
+ remoteJid: entry.chatId,
141
+ fromMe: entry.direction === "outgoing",
142
+ id: `${TRACKED_ID_PREFIX}${entry.callId}`,
143
+ },
144
+ messageTimestamp: Math.floor(entry.at / 1000),
145
+ message: {
146
+ callLogMesssage: {
147
+ isVideo: entry.kind === "video",
148
+ callOutcome: OUTCOME_CODES[entry.outcome],
149
+ ...(entry.durationSeconds === undefined ? {} : { durationSecs: entry.durationSeconds }),
150
+ },
151
+ },
152
+ };
153
+ }
package/dist/config.js CHANGED
@@ -30,6 +30,7 @@ const COMMAND_ARGS = {
30
30
  connect: [1],
31
31
  config: [0, 2],
32
32
  contacts: [1],
33
+ skills: [2],
33
34
  };
34
35
  const COMMANDS = Object.keys(COMMAND_ARGS);
35
36
  export function defaultDataDir() {
@@ -44,6 +45,17 @@ function asInt(value, fallback) {
44
45
  const n = Number.parseInt((value ?? "").trim(), 10);
45
46
  return Number.isFinite(n) ? n : fallback;
46
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
+ }
47
59
  /**
48
60
  * A human at a terminal running bare `wazap` wants to see where they stand, not
49
61
  * a silent MCP server on stdin. Everything else serves, including `wazap serve`.
@@ -88,6 +100,7 @@ export function parseCli(argv = process.argv.slice(2)) {
88
100
  return { kind: "help" };
89
101
  if (values.version)
90
102
  return { kind: "version" };
103
+ dropUnfilledTemplates();
91
104
  const [first, ...args] = positionals;
92
105
  if (first !== undefined && !COMMANDS.includes(first)) {
93
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/index.js CHANGED
@@ -3,6 +3,7 @@ import { BANNER } from "./banner.js";
3
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,12 +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
19
21
  wazap contacts resync Fetch the phone's address book from WhatsApp again
20
22
  wazap status [--live] [--json] Check the install, the session and the server
21
23
  wazap logout Unlink and delete local credentials
22
24
 
23
25
  Clients for wazap connect: ${CLIENT_NAMES}.
26
+ Harnesses for wazap skills install: ${SKILL_TARGET_NAMES}.
24
27
 
25
28
  Options:
26
29
  --data-dir <path> Where wazap keeps its data (default ~/.wazap, or $WAZAP_DATA_DIR)
@@ -32,7 +35,7 @@ Options:
32
35
  --phone <number> Your number in international format; implies --code
33
36
  --agent With setup: print the procedure for an AI agent on stdout, then exit
34
37
  --client <name> With setup: connect this client instead of the detected ones (repeatable)
35
- --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
36
39
  --live With status: reach WhatsApp for real, then close the connection
37
40
  --json With status: print the whole report as one JSON object on stdout
38
41
  --writes Allow the agent to write, without login asking
@@ -73,6 +76,9 @@ async function main() {
73
76
  case "connect":
74
77
  runConnect(config);
75
78
  return;
79
+ case "skills":
80
+ runSkills(config);
81
+ return;
76
82
  case "config":
77
83
  runConfig(config);
78
84
  return;
package/dist/messages.js CHANGED
@@ -165,6 +165,83 @@ function stubKind(raw) {
165
165
  return "deleted";
166
166
  return stub === proto.WebMessageInfo.StubType.UNKNOWN ? undefined : "system";
167
167
  }
168
+ const CALL_OUTCOMES = {
169
+ [proto.Message.CallLogMessage.CallOutcome.CONNECTED]: "answered",
170
+ [proto.Message.CallLogMessage.CallOutcome.ACCEPTED_ELSEWHERE]: "answered",
171
+ [proto.Message.CallLogMessage.CallOutcome.ONGOING]: "answered",
172
+ [proto.Message.CallLogMessage.CallOutcome.REJECTED]: "rejected",
173
+ [proto.Message.CallLogMessage.CallOutcome.MISSED]: "no answer",
174
+ [proto.Message.CallLogMessage.CallOutcome.FAILED]: "no answer",
175
+ [proto.Message.CallLogMessage.CallOutcome.SILENCED_BY_DND]: "no answer",
176
+ [proto.Message.CallLogMessage.CallOutcome.SILENCED_UNKNOWN_CALLER]: "no answer",
177
+ };
178
+ const CALL_STUB_KINDS = {
179
+ [proto.WebMessageInfo.StubType.CALL_MISSED_VOICE]: "voice",
180
+ [proto.WebMessageInfo.StubType.CALL_MISSED_VIDEO]: "video",
181
+ [proto.WebMessageInfo.StubType.CALL_MISSED_GROUP_VOICE]: "voice",
182
+ [proto.WebMessageInfo.StubType.CALL_MISSED_GROUP_VIDEO]: "video",
183
+ };
184
+ function settle(outcome, direction) {
185
+ if (outcome !== "no answer")
186
+ return outcome;
187
+ return direction === "outgoing" ? "unanswered" : "missed";
188
+ }
189
+ /**
190
+ * Calls never reach the RULES table: `getContentType` looks for a key
191
+ * containing "Message" and the proto field is spelled `callLogMesssage`, so it
192
+ * reports undefined and a call arriving next to messageContextInfo would render
193
+ * as "[system message]". Hence this runs before the table, not inside it.
194
+ */
195
+ export function callInfo(raw) {
196
+ const direction = raw.key?.fromMe ? "outgoing" : "incoming";
197
+ const content = unwrapEnvelopes(raw.message);
198
+ const logged = content?.callLogMesssage;
199
+ if (logged) {
200
+ const outcome = settle(CALL_OUTCOMES[logged.callOutcome ?? -1] ?? "no answer", direction);
201
+ const seconds = protoNumber(logged.durationSecs);
202
+ const participants = (logged.participants ?? []).flatMap((one) => (one.jid ? [one.jid] : []));
203
+ return {
204
+ kind: logged.isVideo ? "video" : "voice",
205
+ direction,
206
+ outcome,
207
+ ...(outcome === "answered" && seconds !== undefined && seconds > 0 ? { duration_seconds: seconds } : {}),
208
+ ...(participants.length > 0 ? { participants } : {}),
209
+ };
210
+ }
211
+ const stub = CALL_STUB_KINDS[raw.messageStubType ?? -1];
212
+ if (stub)
213
+ return { kind: stub, direction, outcome: settle("no answer", direction) };
214
+ if (content?.call != null)
215
+ return { kind: "voice", direction, outcome: settle("no answer", direction) };
216
+ return undefined;
217
+ }
218
+ /**
219
+ * Baileys' own stand-in for a group call offer. It says a call happened and
220
+ * nothing else, so anything that names an outcome outranks it.
221
+ */
222
+ export function isCallPlaceholder(raw) {
223
+ const content = unwrapEnvelopes(raw.message);
224
+ return content?.call != null && content.callLogMesssage == null;
225
+ }
226
+ function durationLabel(seconds) {
227
+ if (seconds < 60)
228
+ return `${seconds}s`;
229
+ const minutes = Math.round(seconds / 60);
230
+ if (minutes < 60)
231
+ return `${minutes} min`;
232
+ const hours = Math.floor(minutes / 60);
233
+ const rest = minutes % 60;
234
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest} min`;
235
+ }
236
+ /** An outcome you caused reads as a suffix; one that happened to you is an adjective. */
237
+ export function callText(info) {
238
+ const duration = info.duration_seconds === undefined ? "" : ` · ${durationLabel(info.duration_seconds)}`;
239
+ if (info.direction === "outgoing") {
240
+ return `[outgoing ${info.kind} call${info.outcome === "answered" ? duration : ` · ${info.outcome}`}]`;
241
+ }
242
+ const adjective = info.outcome === "answered" ? "" : `${info.outcome} `;
243
+ return `[${adjective}${info.kind} call${duration}]`;
244
+ }
168
245
  function resolve(value, content) {
169
246
  return typeof value === "function" ? value(content) : value;
170
247
  }
@@ -199,6 +276,8 @@ export function isStubEvent(raw) {
199
276
  return stubKind(raw) !== undefined;
200
277
  }
201
278
  export function messageType(raw) {
279
+ if (callInfo(raw))
280
+ return "call";
202
281
  const stub = stubKind(raw);
203
282
  if (stub === "deleted")
204
283
  return "deleted";
@@ -210,6 +289,11 @@ export function messageType(raw) {
210
289
  }
211
290
  /** Never empty: media and system messages get a placeholder like "[sticker]". */
212
291
  export function messageText(raw) {
292
+ const call = callInfo(raw);
293
+ // The placeholder only says a group call was offered, so naming an outcome
294
+ // ("missed") would claim something the payload never carried.
295
+ if (call)
296
+ return isCallPlaceholder(raw) ? "[group call]" : callText(call);
213
297
  const content = unwrapEnvelopes(raw.message);
214
298
  const { rule, content: node } = ruleFor(content);
215
299
  if (rule === UNKNOWN) {
@@ -295,6 +379,7 @@ export function buildMessageView(raw, ctx) {
295
379
  const media = mediaInfo(raw);
296
380
  const context = contextInfo(raw);
297
381
  const quoted = context?.quotedMessage ? quotedView(context, ctx) : undefined;
382
+ const call = callInfo(raw);
298
383
  const view = {
299
384
  message_id: messageIdFor(raw.key, ctx.chatId),
300
385
  chat_id: ctx.chatId,
@@ -316,6 +401,11 @@ export function buildMessageView(raw, ctx) {
316
401
  view.media = media;
317
402
  if (quoted)
318
403
  view.quoted = quoted;
404
+ if (call) {
405
+ view.call = call.participants
406
+ ? { ...call, participants: call.participants.map((jid) => ctx.canonical(jid)) }
407
+ : call;
408
+ }
319
409
  if (ctx.reactions.length > 0)
320
410
  view.reactions = ctx.reactions;
321
411
  return view;
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
+ }
package/dist/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { asWazapError, ERROR_GUIDE } from "./errors.js";
3
+ import { MESSAGE_TYPES } from "./wa-types.js";
3
4
  function tool(def) {
4
5
  return { ...def, handler: def.handler };
5
6
  }
@@ -25,6 +26,10 @@ const messageId = z
25
26
  .string()
26
27
  .min(5)
27
28
  .describe('Message id from read_messages / search_messages / get_message, e.g. "false_4072...@s.whatsapp.net_3EB0..."');
29
+ const messageTypes = z
30
+ .array(z.enum([...MESSAGE_TYPES]))
31
+ .optional()
32
+ .describe('Keep only these message types; omit for every type. The limit counts matching messages, so ["call"] returns that many calls, not that many messages of which some are calls.');
28
33
  const GUIDE = `# wazap — WhatsApp for your AI agent
29
34
 
30
35
  Read/write access to the user's linked WhatsApp account: chats, messages, media,
@@ -61,6 +66,11 @@ which look like a phone number and are not one.
61
66
  WhatsApp's own notices (device linking, group membership, encryption) have
62
67
  \`type: "system"\` and are left out of get_recent_messages unless you pass
63
68
  include_system: true.
69
+ A WhatsApp call is a message with \`type: "call"\` carrying
70
+ \`call: {kind, direction, outcome, duration_seconds}\`, reading as
71
+ "[voice call · 6 min]" or "[missed voice call]".
72
+ read_messages and get_recent_messages take \`types\` to narrow to a subset of
73
+ these types, e.g. \`types: ["call"]\` for the call log of a chat.
64
74
  \`timestamp\` is ISO 8601 with the machine's UTC offset, \`age\` is human-readable.
65
75
 
66
76
  ## Errors
@@ -142,11 +152,12 @@ older history when the local store runs out, which takes a few seconds.`,
142
152
  chat_id: chatId,
143
153
  limit: z.number().int().min(1).max(200).default(20).describe("Maximum number of messages (1-200)"),
144
154
  before: messageId.optional().describe("Return the messages immediately older than this message_id"),
155
+ types: messageTypes,
145
156
  },
146
157
  write: false,
147
- handler: async ({ chat_id, limit, before }, wa) => {
148
- const result = await wa.readMessages(chat_id, limit, before);
149
- return ok(renderMessages(`Messages in ${chat_id}`, result.data), synced(result, { chat_id, count: result.data.length, messages: result.data }));
158
+ handler: async ({ chat_id, limit, before, types }, wa) => {
159
+ const result = await wa.readMessages(chat_id, limit, before, types);
160
+ return ok(renderMessages(`Messages in ${chat_id}`, result.data), synced(result, { chat_id, types, count: result.data.length, messages: result.data }));
150
161
  },
151
162
  }),
152
163
  tool({
@@ -166,15 +177,17 @@ out so the counts are conversation; pass include_system to see them.`,
166
177
  .boolean()
167
178
  .default(false)
168
179
  .describe("Include WhatsApp's own system notices, which are excluded from the bodies and the counts by default"),
180
+ types: messageTypes,
169
181
  },
170
182
  write: false,
171
- handler: async ({ hours, filter, include_system }, wa) => {
172
- const result = await wa.getRecentMessages(hours, filter, include_system);
183
+ handler: async ({ hours, filter, include_system, types }, wa) => {
184
+ const result = await wa.getRecentMessages(hours, filter, include_system, types);
173
185
  const messageCount = result.data.reduce((n, c) => n + c.messages.length, 0);
174
186
  return ok(renderConversations(result.data, hours), synced(result, {
175
187
  hours,
176
188
  filter,
177
189
  include_system,
190
+ types,
178
191
  conversation_count: result.data.length,
179
192
  message_count: messageCount,
180
193
  conversations: result.data,
package/dist/wa-types.js CHANGED
@@ -1,2 +1,20 @@
1
1
  /** Public shapes of the WhatsApp service: what the MCP tools and the CLI consume. */
2
- export {};
2
+ /** The zod enum the tools expose derives from this, so the two cannot drift. */
3
+ export const MESSAGE_TYPES = [
4
+ "text",
5
+ "image",
6
+ "video",
7
+ "audio",
8
+ "voice",
9
+ "document",
10
+ "sticker",
11
+ "location",
12
+ "contact",
13
+ "poll",
14
+ "reaction",
15
+ "deleted",
16
+ "view_once",
17
+ "call",
18
+ "system",
19
+ "unknown",
20
+ ];
package/dist/whatsapp.js CHANGED
@@ -10,11 +10,12 @@ import { isAbsolute, join } from "node:path";
10
10
  import { setTimeout as sleep } from "node:timers/promises";
11
11
  import makeWASocket, { ALL_WA_PATCH_NAMES, Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
12
12
  import { readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
13
+ import { CallTracker, callMessage, isTrackedCall } from "./calls.js";
13
14
  import { BAILEYS_VERSION, paths, WAZAP_VERSION } from "./config.js";
14
15
  import { asWazapError, RELINK_FIX, RESET_FIX, WazapError } from "./errors.js";
15
16
  import { isGroupId, isNoiseJid, resolveChatId } from "./ids.js";
16
17
  import { log, logError } from "./logger.js";
17
- import { buildMessageView, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
18
+ import { buildMessageView, callInfo, isCallPlaceholder, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, messageType, protoNumber, } from "./messages.js";
18
19
  /** Reconnect pacing. A closed socket used to be retried instantly, which turns
19
20
  * any persistent rejection into a login storm — WhatsApp answers that by
20
21
  * throttling the account and refusing to link *any* new device to it, phone
@@ -35,6 +36,10 @@ const STALE_INBOUND_MS = 24 * 3_600_000;
35
36
  const MAX_MESSAGES_PER_CHAT = 1_000;
36
37
  const PERSIST_MESSAGES_PER_CHAT = 120;
37
38
  const STORE_SAVE_DEBOUNCE_MS = 20_000;
39
+ const CALL_SWEEP_MS = 30_000;
40
+ /** The same call reaches the store up to three ways; only nearness in time tells them apart. */
41
+ const CALL_DEDUPE_WINDOW_MS = 60_000;
42
+ const CALL_DEDUPE_SCAN = 20;
38
43
  const HISTORY_STORE_CAP_PER_CHAT = 2_000;
39
44
  const DIR_MODE = 0o700;
40
45
  const FILE_MODE = 0o600;
@@ -121,13 +126,35 @@ class Store {
121
126
  ring.sort((a, b) => this.seconds(a) - this.seconds(b));
122
127
  while (ring.length > MAX_MESSAGES_PER_CHAT) {
123
128
  const dropped = ring.shift();
124
- if (dropped) {
125
- this.messages.delete(dropped);
126
- this.chatOf.delete(dropped);
127
- this.edited.delete(dropped);
128
- this.reactions.delete(dropped);
129
- }
129
+ if (dropped)
130
+ this.forget(dropped);
131
+ }
132
+ }
133
+ /** The tail of a chat, newest first. */
134
+ recent(chatJid, count) {
135
+ const ring = this.byChat.get(chatJid) ?? [];
136
+ const tail = [];
137
+ for (let i = ring.length - 1; i >= 0 && tail.length < count; i--) {
138
+ const sid = ring[i];
139
+ const raw = this.messages.get(sid);
140
+ if (raw)
141
+ tail.push({ sid, raw });
130
142
  }
143
+ return tail;
144
+ }
145
+ /** Forget one message entirely, its place in the chat included. */
146
+ dropMessage(sid) {
147
+ const ring = this.byChat.get(this.chatOf.get(sid) ?? "");
148
+ const at = ring?.indexOf(sid) ?? -1;
149
+ if (ring && at !== -1)
150
+ ring.splice(at, 1);
151
+ this.forget(sid);
152
+ }
153
+ forget(sid) {
154
+ this.messages.delete(sid);
155
+ this.chatOf.delete(sid);
156
+ this.edited.delete(sid);
157
+ this.reactions.delete(sid);
131
158
  }
132
159
  reactionsFor(sid) {
133
160
  const map = this.reactions.get(sid);
@@ -227,6 +254,7 @@ export class WhatsAppService {
227
254
  historyWaiters = [];
228
255
  storeDirty = false;
229
256
  storeSaveTimer = null;
257
+ callSweepTimer = null;
230
258
  persistedLoaded = false;
231
259
  contactResyncTried = false;
232
260
  blocked = new Set();
@@ -238,6 +266,7 @@ export class WhatsAppService {
238
266
  /** The same, for naming only, and it holds more. See `learnLidPhone`. */
239
267
  lidPhones = new Map();
240
268
  store = new Store();
269
+ calls = new CallTracker();
241
270
  paths;
242
271
  constructor(config) {
243
272
  this.config = config;
@@ -289,6 +318,7 @@ export class WhatsAppService {
289
318
  this.storeSaveTimer = null;
290
319
  this.reconnectTimer = null;
291
320
  this.syncDeadline = null;
321
+ this.stopCallSweep();
292
322
  this.releaseWaiters();
293
323
  await this.flushStore();
294
324
  this.teardownSocket();
@@ -361,7 +391,7 @@ export class WhatsAppService {
361
391
  return this.synced(chats);
362
392
  });
363
393
  }
364
- readMessages(chatId, limit, before) {
394
+ readMessages(chatId, limit, before, types) {
365
395
  return this.guarded(async () => {
366
396
  const sock = this.ensureConnected();
367
397
  const jid = this.resolveId(chatId);
@@ -369,19 +399,19 @@ export class WhatsAppService {
369
399
  await this.learnParticipants(jid);
370
400
  await this.learnLidPhones([jid]);
371
401
  if (before === undefined) {
372
- const ring = this.store.byChat.get(jid) ?? [];
402
+ const ring = this.ofTypes(this.store.byChat.get(jid) ?? [], types);
373
403
  return this.synced(this.viewsFor(ring.slice(-limit), jid));
374
404
  }
375
405
  const anchor = this.messageOrThrow(before);
376
- let older = this.olderThan(jid, before, limit);
406
+ let older = this.olderThan(jid, before, limit, types);
377
407
  if (older.length === 0) {
378
408
  await this.fetchOlder(sock, anchor, limit);
379
- older = this.olderThan(jid, before, limit);
409
+ older = this.olderThan(jid, before, limit, types);
380
410
  }
381
411
  return this.synced(this.viewsFor(older, jid));
382
412
  });
383
413
  }
384
- getRecentMessages(hours, filter, includeSystem = false) {
414
+ getRecentMessages(hours, filter, includeSystem = false, types) {
385
415
  return this.guarded(async () => {
386
416
  this.ensureConnected();
387
417
  await this.waitForSync();
@@ -402,7 +432,7 @@ export class WhatsAppService {
402
432
  });
403
433
  if (recent.length === 0)
404
434
  continue;
405
- const messages = this.viewsFor(recent, jid).filter((view) => includeSystem || view.type !== "system");
435
+ const messages = this.viewsFor(this.ofTypes(recent, types), jid).filter((view) => includeSystem || view.type !== "system");
406
436
  if (messages.length === 0)
407
437
  continue;
408
438
  conversations.push({
@@ -838,6 +868,18 @@ export class WhatsAppService {
838
868
  this.markSyncDone();
839
869
  this.markStoreDirty();
840
870
  });
871
+ sock.ev.on("call", ([call]) => {
872
+ if (generation !== this.generation || !call)
873
+ return;
874
+ // WhatsApp addresses a call node by LID as often as by number, and ownJid
875
+ // is only ever the number, so an outgoing call reads as incoming unless
876
+ // the two are brought into the same form first.
877
+ const from = this.canonical(call.from);
878
+ const entry = this.calls.observe({ ...call, from }, this.ownJid(), Date.now());
879
+ if (entry)
880
+ this.storeCall(entry);
881
+ this.armCallSweep();
882
+ });
841
883
  sock.ev.on("lid-mapping.update", (mapping) => this.learnLid(mapping.lid, mapping.pn));
842
884
  sock.ev.on("chats.upsert", (chats) => {
843
885
  for (const chat of chats)
@@ -1364,12 +1406,26 @@ export class WhatsAppService {
1364
1406
  viewsFor(sids, chatJid) {
1365
1407
  return sids.filter((sid) => this.store.messages.has(sid)).map((sid) => this.viewOf(sid, chatJid));
1366
1408
  }
1367
- olderThan(chatJid, before, limit) {
1409
+ /** Absent and empty both mean every type: narrowing is opt-in, never a default. */
1410
+ ofTypes(sids, types) {
1411
+ if (types === undefined || types.length === 0)
1412
+ return sids;
1413
+ return sids.filter((sid) => {
1414
+ const raw = this.store.messages.get(sid);
1415
+ return raw !== undefined && types.includes(messageType(raw));
1416
+ });
1417
+ }
1418
+ /**
1419
+ * The anchor is found in the unfiltered ring, so paging never depends on the
1420
+ * filter, and `limit` then counts messages the caller asked for rather than
1421
+ * messages we are about to throw away.
1422
+ */
1423
+ olderThan(chatJid, before, limit, types) {
1368
1424
  const ring = this.store.byChat.get(chatJid) ?? [];
1369
1425
  const at = ring.indexOf(before);
1370
1426
  if (at <= 0)
1371
1427
  return [];
1372
- return ring.slice(Math.max(0, at - limit), at);
1428
+ return this.ofTypes(ring.slice(0, at), types).slice(-limit);
1373
1429
  }
1374
1430
  async fetchOlder(sock, anchor, limit) {
1375
1431
  const seconds = Math.floor(messageTimestampMs(anchor) / 1000);
@@ -1499,11 +1555,74 @@ export class WhatsAppService {
1499
1555
  if (isNoiseJid(jid) || isControlMessage(raw))
1500
1556
  continue;
1501
1557
  this.learnPushName(raw, jid);
1502
- this.store.putMessage(messageIdFor(raw.key, jid), jid, raw);
1558
+ const sid = messageIdFor(raw.key, jid);
1559
+ if (!this.keepOverEarlierCall(raw, jid, sid))
1560
+ continue;
1561
+ this.store.putMessage(sid, jid, raw);
1503
1562
  stored.push(raw);
1504
1563
  }
1505
1564
  return stored;
1506
1565
  }
1566
+ /**
1567
+ * One call can reach the store three ways: wazap's own tracker, the stub
1568
+ * baileys synthesises on a timeout, and WhatsApp's later call-log message.
1569
+ * Each carries a different id, so only nearness in time pairs them up, and
1570
+ * whichever says more about the call is the one worth keeping. The history
1571
+ * reload runs it too: the JSONL still holds the line the loser wrote before
1572
+ * it was dropped, and a restart would otherwise bring the pair back.
1573
+ */
1574
+ keepOverEarlierCall(raw, chatJid, sid) {
1575
+ const info = callInfo(raw);
1576
+ if (!info)
1577
+ return true;
1578
+ const at = messageTimestampMs(raw);
1579
+ for (const known of this.store.recent(chatJid, CALL_DEDUPE_SCAN)) {
1580
+ if (known.sid === sid)
1581
+ continue;
1582
+ const other = callInfo(known.raw);
1583
+ if (!other)
1584
+ continue;
1585
+ // A redial inside the window is two calls, and wazap knows it built both.
1586
+ if (isTrackedCall(raw) && isTrackedCall(known.raw))
1587
+ continue;
1588
+ if (Math.abs(messageTimestampMs(known.raw) - at) > CALL_DEDUPE_WINDOW_MS)
1589
+ continue;
1590
+ if (callDetail(raw, info) <= callDetail(known.raw, other))
1591
+ return false;
1592
+ this.store.dropMessage(known.sid);
1593
+ return true;
1594
+ }
1595
+ return true;
1596
+ }
1597
+ /** A live call goes in the way any message does, so everything downstream carries it. */
1598
+ storeCall(entry) {
1599
+ const stored = this.ingestMessages([callMessage(entry)]);
1600
+ if (stored.length === 0)
1601
+ return;
1602
+ void this.appendHistory(stored);
1603
+ this.markStoreDirty();
1604
+ }
1605
+ /**
1606
+ * Only while a call is in flight: a call whose terminal event never arrives
1607
+ * would otherwise sit pending forever, and a timer with nothing to do would
1608
+ * otherwise keep ticking for the life of the process.
1609
+ */
1610
+ armCallSweep() {
1611
+ if (this.callSweepTimer || this.calls.pending === 0)
1612
+ return;
1613
+ this.callSweepTimer = setInterval(() => {
1614
+ for (const entry of this.calls.expire(Date.now()))
1615
+ this.storeCall(entry);
1616
+ if (this.calls.pending === 0)
1617
+ this.stopCallSweep();
1618
+ }, CALL_SWEEP_MS);
1619
+ this.callSweepTimer.unref();
1620
+ }
1621
+ stopCallSweep() {
1622
+ if (this.callSweepTimer)
1623
+ clearInterval(this.callSweepTimer);
1624
+ this.callSweepTimer = null;
1625
+ }
1507
1626
  learnPushName(raw, chatJid) {
1508
1627
  const name = raw.pushName?.trim();
1509
1628
  if (!name || raw.key.fromMe)
@@ -1624,6 +1743,8 @@ export class WhatsAppService {
1624
1743
  const jid = this.canonical(raw.key.remoteJid);
1625
1744
  if (isNoiseJid(jid) || isControlMessage(raw))
1626
1745
  continue;
1746
+ if (!this.keepOverEarlierCall(raw, jid, record.sid))
1747
+ continue;
1627
1748
  this.store.putMessage(record.sid, jid, raw);
1628
1749
  loaded++;
1629
1750
  }
@@ -1674,6 +1795,12 @@ const ADMIN_ACTIONS = new Set([
1674
1795
  const PARTICIPANT_ACTIONS = new Set(["add", "remove", "promote", "demote"]);
1675
1796
  /** WhatsApp answers "cannot add, invite them instead" with these codes. */
1676
1797
  const INVITE_NEEDED_CODES = new Set(["403", "409"]);
1798
+ /** How much a call message says. A duration is the most it can carry. */
1799
+ function callDetail(raw, info) {
1800
+ if (info.duration_seconds !== undefined)
1801
+ return 2;
1802
+ return isCallPlaceholder(raw) ? 0 : 1;
1803
+ }
1677
1804
  function lidKey(lid) {
1678
1805
  return `${jidNormalizedUser(lid).split("@")[0]}@lid`;
1679
1806
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
+ "mcpName": "io.github.razvangirgiz/wazap",
4
5
  "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
5
6
  "license": "MIT",
6
7
  "author": "Răzvan Girgiz",
@@ -26,6 +27,7 @@
26
27
  },
27
28
  "files": [
28
29
  "dist",
30
+ "skills",
29
31
  "README.md",
30
32
  "AGENT.md",
31
33
  "LICENSE"
@@ -36,7 +38,11 @@
36
38
  "dev": "tsx src/index.ts",
37
39
  "typecheck": "tsc --noEmit",
38
40
  "test": "npm run build && node --test test/*.test.mjs",
39
- "prepublishOnly": "npm run build && npm test"
41
+ "prepublishOnly": "npm run build && npm test",
42
+ "registry:validate": "node scripts/validate-server-json.mjs",
43
+ "bundle:mcpb": "node scripts/bundle-mcpb.mjs",
44
+ "icon": "node scripts/make-icon.mjs",
45
+ "context:build": "node scripts/build-context.mjs"
40
46
  },
41
47
  "engines": {
42
48
  "node": ">=20"
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: wazap-setup
3
+ description: Install, link or repair the WhatsApp connection. Use when the user wants to connect WhatsApp to Claude, when a WhatsApp tool returns NOT_LINKED, SESSION_EXPIRED or SESSION_CORRUPT, when `get_status` is anything but connected, or when the user asks how wazap works with Claude Desktop, Claude Code or HTTP.
4
+ ---
5
+
6
+ # wazap setup
7
+
8
+ wazap links the user's own WhatsApp account as a "linked device" and exposes it as MCP tools. The phone must stay online; the link needs the user's hands once.
9
+
10
+ ## Diagnose first
11
+
12
+ Run `npx wazap-mcp status` and branch on its output. It never contacts WhatsApp, so it is safe at any point.
13
+
14
+ | `status` says | Do |
15
+ | --- | --- |
16
+ | `wazap: command not found` / npx fails | Node 20+ is required. `node --version`; install from nodejs.org if older. |
17
+ | `linked: no` | Go to **Link**. |
18
+ | `linked: yes`, `server: running` | The server is up. If tools still fail, call `get_status` and follow its `fix`. |
19
+ | `linked: yes`, `server: not running` | Go to **Connect a client**. |
20
+
21
+ Below those lines is a `checks:` section. Every `✗` carries the command that
22
+ fixes it; run that command rather than improvising.
23
+
24
+ | `checks:` line | What it means |
25
+ | --- | --- |
26
+ | `✗ node` | The Node version is below 20. Nothing else will work until it is upgraded. |
27
+ | `✗ data dir` | Missing, not a directory, mode other than 0700, or not writable. The line names the `chmod` to run. |
28
+ | `– lock: stale` | A previous server died without cleaning up. Harmless; the next start reclaims it. |
29
+ | `✓ lock: held` | A server is running. Do not run `logout` or `status --live`; ask through the client with `get_status`. |
30
+ | `✗ credentials` | Unreadable. `npx wazap-mcp logout` then `npx wazap-mcp login`. |
31
+ | `writes: off` | Write tools are not registered. Enabling them is **Allow writes**. |
32
+ | `– update` | A newer wazap exists, or the check could not reach npm. Never blocking. |
33
+
34
+ `npx wazap-mcp status --live` reaches WhatsApp for real and reports whether the
35
+ phone is reachable, how many chats synced and how old the last message is. It
36
+ refuses while a server holds the lock, because one process owns the session.
37
+ `--json` gives the same report as one object.
38
+
39
+ ## Link
40
+
41
+ Run `npx wazap-mcp setup --agent` and follow what it prints.
42
+
43
+ That procedure starts `login` in the background and reads the
44
+ `pairing code: XXXX-XXXX` line out of its output, so the user is left with the
45
+ one part of linking a machine cannot do, typing the code into the phone.
46
+
47
+ `SESSION_EXPIRED` means the phone removed the device: run `npx wazap-mcp login` again. `SESSION_CORRUPT` means unreadable credentials: `npx wazap-mcp logout` then `npx wazap-mcp login`.
48
+
49
+ ## Connect a client
50
+
51
+ Run `npx wazap-mcp connect <client>`, where the client is one of `claude-code`,
52
+ `claude-desktop`, `cursor`, `codex`, `vscode` or `gemini`. It writes the entry,
53
+ keeps whatever else is in the file, backs it up once, and prints the next step
54
+ (restart, reload window, or `claude mcp list`). Running it twice is safe.
55
+
56
+ - Add `--dry-run` first if the user wants to see the entry before it is written.
57
+ - Claude Code users can install this plugin instead, which registers the server.
58
+ - Remote clients (claude.ai, another machine) need HTTP mode with tokens; follow "HTTP mode" in the wazap README rather than improvising.
59
+
60
+ Done when `get_status` returns `status: "connected"`. Then call `learn` once before using the other tools.
61
+
62
+ ## Allow writes
63
+
64
+ Writes are off unless the user said yes at `login`. The write tools are then not
65
+ registered at all, so the agent cannot see them.
66
+
67
+ Turn them on with `npx wazap-mcp config writes on`, off again with
68
+ `npx wazap-mcp config writes off`. Both edit `WAZAP_READ_ONLY` in
69
+ `<data-dir>/.env`; a running server has to be restarted for the change to take
70
+ effect. `npx wazap-mcp config` alone prints every effective setting and where it
71
+ came from, which is how you tell a flag from an `.env` line.
72
+
73
+ ## Limits the user should hear once
74
+
75
+ Baileys is an unofficial WhatsApp client; Meta can flag accounts, so heavy automated sending is the user's risk. One process owns the session: two wazap servers on the same data dir refuse to start.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: whatsapp-groups
3
+ description: Catch up on a busy WhatsApp group. Use when the user asks what happened in a group, what was decided, what they are being asked to do there, or for a summary of a thread with many messages. Read-only.
4
+ ---
5
+
6
+ # WhatsApp group catch-up
7
+
8
+ Deliverable: decisions, dates, and what is asked of the user, in that order, with the chatter gone. A 300-message thread should compress to a screen.
9
+
10
+ ## Load the whole window
11
+
12
+ 1. Resolve the group with `list_chats` `filter: "groups"` (match on name; ask if two match). `get_group_info` once for the participant names and who the admins are; use names, not numbers, in the summary.
13
+ 2. `read_messages` with `limit: 200`. If the oldest message is still inside the window the user asked for, call again with `before` set to that oldest `message_id`, until the window is covered. Done loading when the oldest message you hold is older than the window, or WhatsApp returns no more.
14
+ 3. Note which messages quote or mention the user: `quoted.sender` equal to the user, the user's name in `text`, or `sender` addressing them directly. These are the **asks**.
15
+
16
+ ## Extract
17
+
18
+ Work through the messages once and collect:
19
+
20
+ - **Decisions**: something agreed or announced by an admin or by the people it concerns ("ok, Saturday at 10 then").
21
+ - **Dates and deadlines**: any concrete day, time, or "by Friday", with what it is for.
22
+ - **Asks of the user**: every mention or reply to them, plus open questions nobody answered that fall on the user. A `call` message in the group after one of these, with `call.outcome` `answered`, means the user was on that group call: say so and treat the ask as probably handled, the way `whatsapp-inbox` does.
23
+ - **Open threads**: questions still without an answer, for anyone.
24
+ - **Polls**: the question and options (`[poll] …`); wazap cannot read votes, so say that if the user asks who voted.
25
+
26
+ Skip greetings, reactions, stickers, and messages that only acknowledge.
27
+
28
+ ## Report
29
+
30
+ ```
31
+ Bloc 12 — 312 messages since Monday
32
+
33
+ Decided
34
+ - Roof repair goes to Tehnoplast, 18,400 lei, vote closed Wednesday.
35
+ - Water off Thursday 09:00–13:00.
36
+
37
+ Dates
38
+ - Fri 15 Mar: pay share (1,150 lei) to the association account.
39
+
40
+ You
41
+ - Mihai asked (Tue) if you can be home Thursday for the plumber. Unanswered.
42
+
43
+ Open
44
+ - Nobody confirmed who holds the basement key.
45
+ ```
46
+
47
+ End with the message count and the window covered, so the user knows what the summary stands on. Replying in the group is the `whatsapp-send` skill's job; here, offer it only for the *You* items.
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: whatsapp-inbox
3
+ description: Triage the user's WhatsApp. Use when they ask what they missed, what's unread, who is waiting on a reply, what needs attention today, or whom they forgot to answer. Read-only; it sends nothing and marks nothing read.
4
+ ---
5
+
6
+ # WhatsApp inbox triage
7
+
8
+ Deliverable: a short, ranked list of what needs the user, with everything else compressed to one line. The user should finish reading in under a minute.
9
+
10
+ ## Collect
11
+
12
+ 1. `get_recent_messages` with the window the user implied (default 24h; "this week" = 168). If the result says `sync: "in_progress"`, wait 5 seconds and call it again once.
13
+ 2. `list_chats` with `filter: "unread"` to catch chats whose activity predates the window.
14
+ 3. For follow-ups ("whom did I forget"): in `list_chats` results, an individual chat whose `last_message.from_me` is false and older than 2 days is an unanswered conversation. Read its last 5 messages with `read_messages` to confirm something was actually asked.
15
+
16
+ Done collecting when every chat with unread messages appears in exactly one bucket below.
17
+
18
+ ## Triage
19
+
20
+ Sort each chat into one bucket:
21
+
22
+ - **Needs you**: a direct question to the user, a request, a mention of the user in a group (`sender` is not the user and the text addresses them or quotes one of their messages), or money/dates/decisions awaiting them.
23
+ - **Probably handled by call**: a *Needs you* candidate the user has since called. See *Calls* below.
24
+ - **FYI**: information with no ask. Shipping updates, "ok thanks", group chatter that reached a conclusion.
25
+ - **Noise**: promotions, broadcast lists, groups the user is muted in (`muted_until` in the future), forwards without a question.
26
+
27
+ Rank *Needs you* by: people over groups, older unanswered over newer, money and deadlines first.
28
+
29
+ ### Calls
30
+
31
+ A call after someone's ask is evidence the user dealt with it. For every *Needs you* candidate from an individual chat, look for a `call` message in that chat newer than the ask: the calls already in the window, or `read_messages` on that chat with `types: ["call"]`. A call whose `call.outcome` is `answered` moves the item to *Probably handled by call*, carrying when it was and how long it ran, and ending in a question, because the call may have been about something else:
32
+
33
+ `Ana — asked about Thursday 10:00; you spoke for 6 min on Tue 14:10. Confirm?`
34
+
35
+ Missed, rejected and unanswered calls are evidence of nothing, and those items stay in *Needs you*.
36
+
37
+ ## Report
38
+
39
+ ```
40
+ Needs you (3)
41
+ 1. Ana — asks if Thursday 10:00 works for the notary. 5h ago.
42
+ 2. Bloc 12 group — Mihai needs your vote on the roof quote by Friday. 1d ago.
43
+ 3. Dan — sent the contract PDF, waiting for your comments. 2d ago.
44
+
45
+ Probably handled by call (1)
46
+ 1. Ana — asked about Thursday 10:00; you spoke for 6 min on Tue 14:10. Confirm?
47
+
48
+ FYI: Curier (delivered), Mama (photos), Team (retro moved to Tuesday).
49
+ Noise: 4 promo chats.
50
+ ```
51
+
52
+ End the report with: *Handled any of these by phone outside WhatsApp? Tell me and I will drop them.* wazap sees WhatsApp calls and never cellular ones, so a call from the phone's own dialler leaves no trace here. Whatever the user answers is authoritative for the rest of the session: drop what they name and do not raise it again.
53
+
54
+ One line per item: who, what they want, how old. Include the `chat_id` only if the user is likely to act through another tool next. Offer to draft replies only for *Needs you* items; drafting and sending belong to the `whatsapp-send` skill.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: whatsapp-recall
3
+ description: Find something in the user's WhatsApp history. Use when they ask for an address, invoice, photo, document, link, phone number, date or a thing someone said or sent, or "what did X say about Y". Read-only.
4
+ ---
5
+
6
+ # WhatsApp recall
7
+
8
+ Deliverable: the exact message or file, quoted with who sent it and when, or a clear "not found" that says where you looked.
9
+
10
+ ## Narrow, then search
11
+
12
+ 1. If a person or group is named, resolve it with `search_contacts` (people) or `list_chats` with `filter: "groups"` (groups) and search inside that `chat_id`. Searching one chat beats searching everything.
13
+ 2. `search_messages` with the user's words. If nothing matches, run two more variants before giving up: a synonym or the other language the user writes in, and a narrower fragment (a street name instead of "the address", "factura" instead of "the invoice from March").
14
+ 3. Media has no searchable text beyond its caption and the `[image]`/`[document] name.pdf` placeholder. For "the photo of…" or "the PDF", search the placeholder and filename words, or `read_messages` on the chat with `limit: 100` and scan `type` and `media.filename`.
15
+ 4. Older than what is loaded: `read_messages` with `before` set to the oldest `message_id` you have, repeatedly, until the date the user remembers is covered or WhatsApp returns nothing more.
16
+
17
+ Done searching when you have a match, or all three query variants and the pagination step came back empty.
18
+
19
+ ## Deliver
20
+
21
+ - A message: quote the text verbatim, then sender, chat, timestamp. Example: *"Str. Lunii 14, ap. 3, interfon 31" — Ana, 12 Mar 14:05.*
22
+ - A file or photo: `download_media` on the message, then open the saved file with your file-reading tool and answer from its contents when the user asked a question about it (an amount, a clause, a date). Give the saved path so the user can open it.
23
+ - A link: return the URL as sent; do not fetch it unless asked.
24
+ - Several candidates: list up to 5 with sender and date and ask which one, rather than guessing.
25
+ - Not found: say which chats and which phrases you tried, and whether `MEDIA_UNAVAILABLE` blocked a download (the sender must resend).
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: whatsapp-send
3
+ description: Send, reply, forward, share a file, react, or create a poll on WhatsApp on the user's behalf. Use for any request that results in an outgoing WhatsApp message. Drafts first and sends after the user approves; messages leave from the user's own account and cannot be unsent.
4
+ ---
5
+
6
+ # WhatsApp send
7
+
8
+ A message sent here is indistinguishable from one the user typed. The rail: **the user sees recipient and exact text, says yes, then it goes.** One approval covers one message to one chat.
9
+
10
+ ## Resolve the recipient
11
+
12
+ 1. `search_contacts` with the name. Exactly one match: use its `chat_id`. Several: list them with numbers and ask. None: ask for the number in international format; `NOT_ON_WHATSAPP` means the number is wrong, not that you should retry.
13
+ 2. Groups come from `list_chats` with `filter: "groups"`. Before posting, `get_group_info`; if `announcement_only` is true and the user is not admin, say so instead of trying.
14
+ 3. A reply to a specific message needs its `message_id` from `read_messages`; pass it as `reply_to` so the quote shows.
15
+
16
+ ## Draft
17
+
18
+ 1. `read_messages` on the chat, `limit: 20`, and match the register already in use: language (Romanian or English), formality, emoji, length. A two-line chat gets a two-line reply.
19
+ 2. Write the message as the user, first person, without a signature or "sent by an assistant".
20
+ 3. Files: `send_media` needs a local `file_path` that exists on the machine running wazap, or a public URL. Check the path before drafting; pick `as_document: true` for PDFs and anything the recipient should keep at original quality, `as_voice: true` only for audio meant as a voice note.
21
+
22
+ ## Confirm, then send
23
+
24
+ Show exactly this and wait for a yes:
25
+
26
+ ```
27
+ To: Ana (+40 722 …)
28
+ "Joi la 10 e perfect, ne vedem la notar. Aduc eu actele."
29
+ ```
30
+
31
+ Send on the user's yes with `send_message` / `send_media` / `send_poll` / `send_location` / `forward_message`, and report the result with the `message_id` so the user can follow up with `edit_message` (own messages, 15 minutes) or `delete_message`.
32
+
33
+ Approval is per message, even after "just send it" for a batch, when the recipient is a group, a number not in the user's contacts, or the content contains money, dates, or commitments. For a batch of plain messages the user already approved as a list, send them one by one and stop at the first error; `RATE_LIMITED` means wait the seconds in `fix`, then continue.
34
+
35
+ ## Out of scope
36
+
37
+ `delete_message` with `for_everyone` and `manage_group` remove/leave run only on an explicit ask naming the message or person. Bulk sends to people who did not write first are the user's account at risk of a WhatsApp ban; say that once and let them decide.