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 +106 -8
- package/dist/calls.js +153 -0
- package/dist/config.js +13 -0
- package/dist/connect.js +23 -2
- package/dist/index.js +7 -1
- package/dist/messages.js +90 -0
- package/dist/skills.js +90 -0
- package/dist/tools.js +18 -5
- package/dist/wa-types.js +19 -1
- package/dist/whatsapp.js +143 -16
- package/package.json +8 -2
- package/skills/wazap-setup/SKILL.md +75 -0
- package/skills/whatsapp-groups/SKILL.md +47 -0
- package/skills/whatsapp-inbox/SKILL.md +54 -0
- package/skills/whatsapp-recall/SKILL.md +25 -0
- package/skills/whatsapp-send/SKILL.md +37 -0
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
### Other MCP clients
|
|
88
|
+
|
|
89
|
+
Cursor and VS Code install from a link:
|
|
90
|
+
|
|
91
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=whatsapp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIndhemFwLW1jcCJdfQ)
|
|
92
|
+
[](vscode:mcp/install?%7B%22name%22%3A%22whatsapp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D)
|
|
93
|
+
|
|
94
|
+
Both carry the same entry `connect` writes. Where a custom scheme is stripped
|
|
95
|
+
before you can click it, VS Code also takes
|
|
96
|
+
[the https form](https://insiders.vscode.dev/redirect/mcp/install?name=whatsapp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22wazap-mcp%22%5D%7D).
|
|
97
|
+
`node scripts/badges.mjs` reprints all three.
|
|
98
|
+
|
|
99
|
+
Any other MCP client works the same way: the command is `npx -y wazap-mcp`, the
|
|
100
|
+
transport is stdio. Tell the agent to call `learn` first — it returns the id
|
|
101
|
+
formats, the workflows and every error code with what to do about it.
|
|
71
102
|
|
|
72
103
|
<details>
|
|
73
104
|
<summary>The raw entries, for editing by hand</summary>
|
|
@@ -83,8 +114,9 @@ workflows and every error code with what to do about it.
|
|
|
83
114
|
}
|
|
84
115
|
```
|
|
85
116
|
|
|
86
|
-
Claude Desktop, Cursor
|
|
87
|
-
`servers` and wants a `"type": "stdio"` alongside `command`. Codex CLI
|
|
117
|
+
Claude Desktop, Cursor, Gemini CLI and Windsurf take exactly that. VS Code nests
|
|
118
|
+
it under `servers` and wants a `"type": "stdio"` alongside `command`. Codex CLI
|
|
119
|
+
is TOML:
|
|
88
120
|
|
|
89
121
|
```toml
|
|
90
122
|
[mcp_servers.whatsapp]
|
|
@@ -92,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
|
-
|
|
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
|
-
|
|
61
|
+
value: (entry) => ({ type: "stdio", ...entry }),
|
|
62
62
|
next: "Written to ./.vscode/mcp.json for this workspace. Reload the VS Code window.",
|
|
63
63
|
detect: (probe) => probe.onPath("code"),
|
|
64
64
|
},
|
|
@@ -71,6 +71,27 @@ export const CLIENTS = [
|
|
|
71
71
|
next: "Restart the Gemini CLI.",
|
|
72
72
|
detect: (probe) => probe.exists(join(homedir(), ".gemini")),
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "windsurf",
|
|
76
|
+
describe: "Windsurf",
|
|
77
|
+
file: () => join(homedir(), ".codeium", "windsurf", "mcp_config.json"),
|
|
78
|
+
format: "json",
|
|
79
|
+
keyPath: ["mcpServers", "whatsapp"],
|
|
80
|
+
next: "Refresh the MCP servers in Windsurf's Cascade panel.",
|
|
81
|
+
detect: (probe) => probe.exists(join(homedir(), ".codeium", "windsurf")),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "opencode",
|
|
85
|
+
describe: "OpenCode",
|
|
86
|
+
file: () => join(homedir(), ".config", "opencode", "opencode.json"),
|
|
87
|
+
format: "json",
|
|
88
|
+
keyPath: ["mcp", "whatsapp"],
|
|
89
|
+
// OpenCode takes one array where the others take a command and its args,
|
|
90
|
+
// and its schema refuses anything else under the key.
|
|
91
|
+
value: (entry) => ({ type: "local", command: [entry.command, ...entry.args] }),
|
|
92
|
+
next: "Restart OpenCode.",
|
|
93
|
+
detect: (probe) => probe.exists(join(homedir(), ".config", "opencode")),
|
|
94
|
+
},
|
|
74
95
|
];
|
|
75
96
|
export const CLIENT_NAMES = CLIENTS.map((client) => client.name).join(", ");
|
|
76
97
|
const OTHER_CLIENTS = CLIENTS.slice(1)
|
|
@@ -173,7 +194,7 @@ function writeJsonEntry(spec, entry, dryRun) {
|
|
|
173
194
|
}
|
|
174
195
|
doc = parsed;
|
|
175
196
|
}
|
|
176
|
-
const value =
|
|
197
|
+
const value = spec.value?.(entry) ?? { command: entry.command, args: entry.args };
|
|
177
198
|
setIn(doc, spec.keyPath, value);
|
|
178
199
|
// Indent 1 collapsed to one line: short enough to read, still spaced like JSON.
|
|
179
200
|
const shown = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ");
|
package/dist/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
|
-
|
|
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.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|