wazap-mcp 0.9.3 → 0.9.5
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 +8 -3
- package/dist/auth-state.js +25 -0
- package/dist/cli.js +56 -3
- package/dist/config.js +1 -0
- package/dist/ids.js +21 -0
- package/dist/index.js +5 -1
- package/dist/messages.js +61 -4
- package/dist/tools.js +45 -5
- package/dist/whatsapp.js +328 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
**WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
|
|
11
|
-
chats, messages, media, contacts, groups — behind
|
|
11
|
+
chats, messages, media, contacts, groups — behind 23 tools any MCP client can
|
|
12
12
|
call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
|
|
13
13
|
|
|
14
14
|
Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
|
|
@@ -101,13 +101,14 @@ The `skills/` folder follows the [Agent Skills](https://agentskills.io) format,
|
|
|
101
101
|
| Tool | Kind | What it does |
|
|
102
102
|
| --- | --- | --- |
|
|
103
103
|
| `learn` | read | The guide to every tool, id format and error code. Call it first. |
|
|
104
|
-
| `get_status` | read | Connection status, sync state, linked account, versions, data dir. |
|
|
104
|
+
| `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
|
|
105
105
|
| `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
|
|
106
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. |
|
|
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. |
|
|
108
108
|
| `search_messages` | read | Text search across the locally held messages. |
|
|
109
109
|
| `get_message` | read | One message in full, with its quoted message and reactions. |
|
|
110
110
|
| `search_contacts` | read | Find contacts by name or number. |
|
|
111
|
+
| `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |
|
|
111
112
|
| `get_contact` | read | Name, number, about text, profile picture. |
|
|
112
113
|
| `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |
|
|
113
114
|
| `download_media` | read | Save an attachment to disk; small images also come back inline. |
|
|
@@ -307,6 +308,10 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
|
|
|
307
308
|
- **`@lid` ids.** Newer accounts are addressed by a privacy id rather than a
|
|
308
309
|
phone number. wazap translates them back to phone numbers when it has learned
|
|
309
310
|
the mapping, and passes the `@lid` through when it has not.
|
|
311
|
+
- **Names come from the phone's address book.** WhatsApp delivers it as an app
|
|
312
|
+
state sync, and only to a connection asking for it from scratch. If contacts
|
|
313
|
+
read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
|
|
314
|
+
again with the `sync_contacts` tool or `wazap contacts resync`.
|
|
310
315
|
- **Your phone must stay reachable.** A linked device stops receiving once the
|
|
311
316
|
phone has been offline long enough; `get_status` says so in `hint`.
|
|
312
317
|
|
package/dist/auth-state.js
CHANGED
|
@@ -117,3 +117,28 @@ export function readLinkedAccount(dir) {
|
|
|
117
117
|
export function clearAuth(dir) {
|
|
118
118
|
rmSync(dir, { recursive: true, force: true });
|
|
119
119
|
}
|
|
120
|
+
const APP_STATE_SYNC_VERSION = "app-state-sync-version";
|
|
121
|
+
/**
|
|
122
|
+
* The same auth state with the app state sync journal held at zero: reads of it
|
|
123
|
+
* find nothing and writes to it are dropped, every other key type untouched.
|
|
124
|
+
*
|
|
125
|
+
* WhatsApp delivers the phone's address book to a companion as `contactAction`
|
|
126
|
+
* mutations in the app state sync, once per stored collection version. Whichever
|
|
127
|
+
* socket saves those versions consumes that one delivery; every later connection
|
|
128
|
+
* resyncs from the version it left behind and receives nothing. The login socket
|
|
129
|
+
* has no store to put contacts in, so it must leave the journal for the service
|
|
130
|
+
* that follows.
|
|
131
|
+
*/
|
|
132
|
+
export function withoutAppStateSync(state) {
|
|
133
|
+
return {
|
|
134
|
+
creds: state.creds,
|
|
135
|
+
keys: {
|
|
136
|
+
get: async (type, ids) => type === APP_STATE_SYNC_VERSION ? {} : state.keys.get(type, ids),
|
|
137
|
+
set: async (data) => {
|
|
138
|
+
const rest = { ...data };
|
|
139
|
+
delete rest[APP_STATE_SYNC_VERSION];
|
|
140
|
+
await state.keys.set(rest);
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
5
5
|
import makeWASocket, { DisconnectReason, } from "baileys";
|
|
6
6
|
import qrcode from "qrcode";
|
|
7
7
|
import qrcodeTerminal from "qrcode-terminal";
|
|
8
|
-
import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
8
|
+
import { clearAuth, readLinkedAccount, useAtomicAuthState, withoutAppStateSync, } from "./auth-state.js";
|
|
9
9
|
import { banner } from "./banner.js";
|
|
10
10
|
import { runBridge } from "./bridge.js";
|
|
11
11
|
import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
|
|
@@ -20,7 +20,7 @@ import { formatAge } from "./messages.js";
|
|
|
20
20
|
import { RateLimiter } from "./ratelimit.js";
|
|
21
21
|
import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
|
|
22
22
|
import { applyWrites } from "./settings.js";
|
|
23
|
-
import { bold, box, brand, humanLayout, dim, fail, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
|
|
23
|
+
import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
|
|
24
24
|
import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
|
|
25
25
|
const LOGIN_TIMEOUT_MS = 120_000;
|
|
26
26
|
const LIVE_TIMEOUT_MS = 15_000;
|
|
@@ -194,6 +194,53 @@ async function runLiveProbe(config) {
|
|
|
194
194
|
releaseLock(p.lockFile);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* `wazap contacts resync`. One process owns the session, so this refuses while a
|
|
199
|
+
* server holds it rather than fighting for the socket: that server has the
|
|
200
|
+
* sync_contacts tool, which does the same thing.
|
|
201
|
+
*/
|
|
202
|
+
export async function runContacts(config) {
|
|
203
|
+
if (config.args[0] !== "resync") {
|
|
204
|
+
say(fail(`Unknown contacts command "${config.args[0]}".`));
|
|
205
|
+
say(fix("Run `wazap contacts resync`"));
|
|
206
|
+
process.exit(2);
|
|
207
|
+
}
|
|
208
|
+
const p = paths(config.dataDir);
|
|
209
|
+
const running = takeSessionLock(p.lockFile);
|
|
210
|
+
if (running !== null) {
|
|
211
|
+
say(fail(`wazap is running (pid ${running}).`));
|
|
212
|
+
say(fix("ask your agent for the sync_contacts tool, or stop the server and run this again"));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
const wa = new WhatsAppService(config);
|
|
216
|
+
const spin = spinner("Asking WhatsApp for your address book…");
|
|
217
|
+
try {
|
|
218
|
+
await wa.start();
|
|
219
|
+
const deadline = Date.now() + LIVE_TIMEOUT_MS;
|
|
220
|
+
let probe = wa.getStatus();
|
|
221
|
+
while (!SETTLED_STATUSES.includes(probe.status) && Date.now() < deadline) {
|
|
222
|
+
await sleep(250);
|
|
223
|
+
probe = wa.getStatus();
|
|
224
|
+
}
|
|
225
|
+
const result = await wa.syncContacts();
|
|
226
|
+
spin.stop(result.named_after > result.named_before
|
|
227
|
+
? ok(`${result.named_after} contacts have a name (was ${result.named_before})`)
|
|
228
|
+
: result.named_after > 0
|
|
229
|
+
? ok(`Already up to date: ${result.named_after} contacts have a name`)
|
|
230
|
+
: warn("WhatsApp sent no names at all. The phone has no saved contacts for these people."));
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const failure = asWazapError(err);
|
|
234
|
+
spin.stop(fail(failure.message));
|
|
235
|
+
if (failure.fix)
|
|
236
|
+
say(fix(failure.fix));
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await wa.stop();
|
|
241
|
+
releaseLock(p.lockFile);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
197
244
|
/** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
|
|
198
245
|
export async function runGreet(config) {
|
|
199
246
|
say(banner());
|
|
@@ -580,10 +627,16 @@ async function linkSession(authDir, opts) {
|
|
|
580
627
|
if (expired)
|
|
581
628
|
throw timedOut();
|
|
582
629
|
const { state, saveCreds } = await useAtomicAuthState(authDir);
|
|
630
|
+
// This socket pairs and nothing else. It has no store, so anything it
|
|
631
|
+
// syncs is thrown away — and WhatsApp sends the history and the address
|
|
632
|
+
// book once. Refusing the history keeps it out of Baileys' sync state
|
|
633
|
+
// machine, which is what would otherwise bump `accountSyncCounter` and
|
|
634
|
+
// leave the service permanently past its own first sync.
|
|
583
635
|
const sock = makeWASocket({
|
|
584
|
-
auth: state,
|
|
636
|
+
auth: withoutAppStateSync(state),
|
|
585
637
|
browser: WA_BROWSER,
|
|
586
638
|
markOnlineOnConnect: false,
|
|
639
|
+
shouldSyncHistoryMessage: () => false,
|
|
587
640
|
logger: SILENT_LOGGER,
|
|
588
641
|
});
|
|
589
642
|
current = sock;
|
package/dist/config.js
CHANGED
package/dist/ids.js
CHANGED
|
@@ -11,6 +11,27 @@ export function normalizePhone(input) {
|
|
|
11
11
|
export function isGroupId(jid) {
|
|
12
12
|
return jid.endsWith("@g.us");
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Jids that address nobody: the status feed, the `0@s.whatsapp.net` pseudo-chat
|
|
16
|
+
* WhatsApp files its own notices under, and anything malformed. They must never
|
|
17
|
+
* reach a chat list, a digest or the store.
|
|
18
|
+
*
|
|
19
|
+
* Stated as what to refuse rather than what to keep, so a jid kind wazap has
|
|
20
|
+
* not met yet — a broadcast list, a channel — still reaches the user instead of
|
|
21
|
+
* being silently swallowed, and a stored one is never purged.
|
|
22
|
+
*/
|
|
23
|
+
export function isNoiseJid(jid) {
|
|
24
|
+
const at = jid.lastIndexOf("@");
|
|
25
|
+
if (at === -1)
|
|
26
|
+
return true;
|
|
27
|
+
const user = jid.slice(0, at);
|
|
28
|
+
const domain = jid.slice(at + 1).toLowerCase();
|
|
29
|
+
if (domain === "broadcast")
|
|
30
|
+
return user.toLowerCase() === "status";
|
|
31
|
+
if (domain === "s.whatsapp.net" || domain === "c.us")
|
|
32
|
+
return /^0+$/.test(user) || !/^\d+$/.test(user);
|
|
33
|
+
return user === "";
|
|
34
|
+
}
|
|
14
35
|
/**
|
|
15
36
|
* Canonicalize anything a caller may pass as a chat id.
|
|
16
37
|
* Individuals become `<digits>@s.whatsapp.net`, groups stay `<id>@g.us`.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { BANNER } from "./banner.js";
|
|
3
|
-
import { runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
|
|
3
|
+
import { runContacts, runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
|
|
4
4
|
import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
|
|
5
5
|
import { CLIENT_NAMES, runConnect } from "./connect.js";
|
|
6
6
|
import { runSetup } from "./setup.js";
|
|
@@ -16,6 +16,7 @@ Usage:
|
|
|
16
16
|
wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
|
|
17
17
|
wazap connect <client> [--dry-run] Register wazap with an MCP client
|
|
18
18
|
wazap config [writes on|off] Show the effective settings, or allow/refuse writes
|
|
19
|
+
wazap contacts resync Fetch the phone's address book from WhatsApp again
|
|
19
20
|
wazap status [--live] [--json] Check the install, the session and the server
|
|
20
21
|
wazap logout Unlink and delete local credentials
|
|
21
22
|
|
|
@@ -75,6 +76,9 @@ async function main() {
|
|
|
75
76
|
case "config":
|
|
76
77
|
runConfig(config);
|
|
77
78
|
return;
|
|
79
|
+
case "contacts":
|
|
80
|
+
await runContacts(config);
|
|
81
|
+
return;
|
|
78
82
|
case "status":
|
|
79
83
|
await runStatus(config);
|
|
80
84
|
return;
|
package/dist/messages.js
CHANGED
|
@@ -14,7 +14,8 @@ export function messageTimestampMs(raw) {
|
|
|
14
14
|
const seconds = protoNumber(raw.messageTimestamp);
|
|
15
15
|
return seconds === undefined ? Date.now() : seconds * 1000;
|
|
16
16
|
}
|
|
17
|
-
/** Stable across restarts
|
|
17
|
+
/** Stable across restarts. Not stable if the chat's own jid is later remapped
|
|
18
|
+
* from a LID to a phone number, which is why wazap does not remap chat jids. */
|
|
18
19
|
export function messageIdFor(key, chatId) {
|
|
19
20
|
return `${key.fromMe ? "true" : "false"}_${chatId}_${key.id ?? ""}`;
|
|
20
21
|
}
|
|
@@ -50,7 +51,28 @@ const PROTOCOL = {
|
|
|
50
51
|
tag: (m) => (isRevoke(m) ? DELETED_TEXT : SYSTEM_TEXT),
|
|
51
52
|
};
|
|
52
53
|
const SYSTEM = { type: "system", tag: SYSTEM_TEXT };
|
|
53
|
-
|
|
54
|
+
/** Names the payload, so a bug report says which one to add. */
|
|
55
|
+
const UNKNOWN = {
|
|
56
|
+
type: "unknown",
|
|
57
|
+
tag: (m) => {
|
|
58
|
+
const key = getContentType(m) ?? Object.keys(m).find((name) => m[name] != null);
|
|
59
|
+
return key ? `[unsupported: ${key}]` : "[unsupported message]";
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
/** Payloads WhatsApp exchanges with its own clients; no person ever sent one. */
|
|
63
|
+
const CONTROL_KEYS = ["messageContextInfo", "senderKeyDistributionMessage"];
|
|
64
|
+
/**
|
|
65
|
+
* The protocol messages that report something a person did. Baileys enumerates
|
|
66
|
+
* exactly these as cross-user in Utils/process-message.js — every other type is
|
|
67
|
+
* one device talking to another. MESSAGE_EDIT is cross-user too, but wazap
|
|
68
|
+
* applies the edit to the message it edits, so its envelope is not a second
|
|
69
|
+
* message to show.
|
|
70
|
+
*/
|
|
71
|
+
const REPORTABLE_PROTOCOL_TYPES = new Set([
|
|
72
|
+
proto.Message.ProtocolMessage.Type.REVOKE,
|
|
73
|
+
proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
|
|
74
|
+
proto.Message.ProtocolMessage.Type.GROUP_MEMBER_LABEL_CHANGE,
|
|
75
|
+
]);
|
|
54
76
|
/**
|
|
55
77
|
* One table drives both messageType and messageText, so the reported type and
|
|
56
78
|
* the placeholder can never disagree.
|
|
@@ -126,8 +148,13 @@ function ruleFor(content) {
|
|
|
126
148
|
const rule = key ? RULES[key] : undefined;
|
|
127
149
|
if (rule)
|
|
128
150
|
return { rule, content };
|
|
129
|
-
|
|
151
|
+
// Only when the control keys are all there is. A payload wazap does not model
|
|
152
|
+
// yet usually carries messageContextInfo alongside it, and calling that a
|
|
153
|
+
// system message would hide someone's event, album or order behind
|
|
154
|
+
// "[system message]" and then out of the digest.
|
|
155
|
+
if (key === undefined && (content.messageContextInfo || content.senderKeyDistributionMessage)) {
|
|
130
156
|
return { rule: SYSTEM, content };
|
|
157
|
+
}
|
|
131
158
|
return { rule: UNKNOWN, content };
|
|
132
159
|
}
|
|
133
160
|
function stubKind(raw) {
|
|
@@ -141,6 +168,36 @@ function stubKind(raw) {
|
|
|
141
168
|
function resolve(value, content) {
|
|
142
169
|
return typeof value === "function" ? value(content) : value;
|
|
143
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* True for the machinery WhatsApp runs between devices: history-sync notices,
|
|
173
|
+
* app-state and peer-data responses, sender-key distribution, bare context
|
|
174
|
+
* info. They carry nothing a person did, so they are dropped rather than shown.
|
|
175
|
+
*/
|
|
176
|
+
export function isControlMessage(raw) {
|
|
177
|
+
if (isStubEvent(raw))
|
|
178
|
+
return false;
|
|
179
|
+
const content = unwrapEnvelopes(raw.message);
|
|
180
|
+
if (!content)
|
|
181
|
+
return true;
|
|
182
|
+
const key = getContentType(content);
|
|
183
|
+
if (key === "protocolMessage")
|
|
184
|
+
return !REPORTABLE_PROTOCOL_TYPES.has(content.protocolMessage?.type ?? -1);
|
|
185
|
+
if (key !== undefined)
|
|
186
|
+
return false;
|
|
187
|
+
// getContentType ignores the control keys, so reaching here means the payload
|
|
188
|
+
// is either nothing at all or nothing but control keys.
|
|
189
|
+
const present = Object.keys(content).filter((name) => content[name] != null);
|
|
190
|
+
return present.length === 0 || present.every((name) => CONTROL_KEYS.includes(name));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A message whose whole content is a stub type: WhatsApp's own notices about
|
|
194
|
+
* device linking, group membership and encryption. Baileys builds these with no
|
|
195
|
+
* `message` field at all, so they have to be recognised before the usual
|
|
196
|
+
* "no content, nothing to store" guard throws them away.
|
|
197
|
+
*/
|
|
198
|
+
export function isStubEvent(raw) {
|
|
199
|
+
return stubKind(raw) !== undefined;
|
|
200
|
+
}
|
|
144
201
|
export function messageType(raw) {
|
|
145
202
|
const stub = stubKind(raw);
|
|
146
203
|
if (stub === "deleted")
|
|
@@ -244,7 +301,7 @@ export function buildMessageView(raw, ctx) {
|
|
|
244
301
|
from_me: Boolean(raw.key.fromMe),
|
|
245
302
|
sender: {
|
|
246
303
|
id: sender,
|
|
247
|
-
name: ctx.nameFor(sender)
|
|
304
|
+
name: ctx.nameFor(sender),
|
|
248
305
|
...(phoneOf(sender) ? { phone: phoneOf(sender) } : {}),
|
|
249
306
|
},
|
|
250
307
|
type: messageType(raw),
|
package/dist/tools.js
CHANGED
|
@@ -42,7 +42,9 @@ contacts and groups. Call get_status first if anything looks wrong.
|
|
|
42
42
|
- Catch up: get_recent_messages(hours) for everything, or list_chats(filter:"unread")
|
|
43
43
|
then read_messages(chat_id).
|
|
44
44
|
- Go back further: read_messages(chat_id, before: <oldest message_id you have>).
|
|
45
|
-
- Find a person: search_contacts → get_contact.
|
|
45
|
+
- Find a person: search_contacts → get_contact. Names come from the phone's own
|
|
46
|
+
address book; if they are missing (get_status shows contacts_named: 0), call
|
|
47
|
+
sync_contacts once.
|
|
46
48
|
- Find something said: search_messages(query[, chat_id]).
|
|
47
49
|
- Send: send_message / send_media / send_poll / send_location. These are REAL
|
|
48
50
|
messages from the user's own account and there is no undo. Confirm the
|
|
@@ -53,6 +55,12 @@ contacts and groups. Call get_status first if anything looks wrong.
|
|
|
53
55
|
## Message shape
|
|
54
56
|
Every message has non-empty \`text\`: media and system messages carry a
|
|
55
57
|
placeholder like "[image] caption", "[voice message]", "[deleted]", "[poll] question".
|
|
58
|
+
A sender whose name WhatsApp has never given us reads as their phone number, or
|
|
59
|
+
as "unknown (lid …1234)" when even that is unknown — never as raw LID digits,
|
|
60
|
+
which look like a phone number and are not one.
|
|
61
|
+
WhatsApp's own notices (device linking, group membership, encryption) have
|
|
62
|
+
\`type: "system"\` and are left out of get_recent_messages unless you pass
|
|
63
|
+
include_system: true.
|
|
56
64
|
\`timestamp\` is ISO 8601 with the machine's UTC offset, \`age\` is human-readable.
|
|
57
65
|
|
|
58
66
|
## Errors
|
|
@@ -76,7 +84,8 @@ code with what to do about it. Takes no arguments and never touches WhatsApp.`,
|
|
|
76
84
|
description: `Check the session: connection status ("connected" means the tools work,
|
|
77
85
|
"not_linked" means the user must run \`npx wazap-mcp login\`), whether the initial
|
|
78
86
|
history sync has finished, which account is linked, when a message last arrived,
|
|
79
|
-
|
|
87
|
+
the versions and data directory in use, and how many contacts carry a name from
|
|
88
|
+
the phone's address book (contacts_named: 0 means it never arrived).
|
|
80
89
|
|
|
81
90
|
Call this whenever another tool reports NOT_CONNECTED, NOT_LINKED or
|
|
82
91
|
SYNC_IN_PROGRESS, or to confirm which account you are about to send from.`,
|
|
@@ -89,6 +98,7 @@ SYNC_IN_PROGRESS, or to confirm which account you are about to send from.`,
|
|
|
89
98
|
`# WhatsApp: ${s.status} (sync: ${s.sync})`,
|
|
90
99
|
`- **account**: ${account}`,
|
|
91
100
|
`- **last message received**: ${s.last_message_received_at ?? "never"}`,
|
|
101
|
+
`- **contacts named**: ${s.contacts_named}`,
|
|
92
102
|
`- **data dir**: ${s.data_dir} · **read-only**: ${s.read_only} · **rate limit**: ${s.rate_limit}/min`,
|
|
93
103
|
`- **versions**: wazap ${s.wazap_version}, baileys ${s.baileys_version}`,
|
|
94
104
|
s.last_error ? `- **last error**: ${s.last_error}` : null,
|
|
@@ -143,21 +153,28 @@ older history when the local store runs out, which takes a few seconds.`,
|
|
|
143
153
|
name: "get_recent_messages",
|
|
144
154
|
title: "Get every WhatsApp conversation from the last N hours",
|
|
145
155
|
description: `Everything that happened recently, grouped by chat. This is the catch-up tool:
|
|
146
|
-
one call instead of list_chats plus a read_messages per chat
|
|
156
|
+
one call instead of list_chats plus a read_messages per chat. WhatsApp's own
|
|
157
|
+
notices — device linking, group membership changes, encryption notices — are left
|
|
158
|
+
out so the counts are conversation; pass include_system to see them.`,
|
|
147
159
|
schema: {
|
|
148
160
|
hours: z.number().int().min(1).max(168).default(24).describe("Look-back window in hours (1-168)"),
|
|
149
161
|
filter: z
|
|
150
162
|
.enum(["all", "unread", "groups", "individual"])
|
|
151
163
|
.default("all")
|
|
152
164
|
.describe("Restrict to unread chats, groups, or one-to-one chats"),
|
|
165
|
+
include_system: z
|
|
166
|
+
.boolean()
|
|
167
|
+
.default(false)
|
|
168
|
+
.describe("Include WhatsApp's own system notices, which are excluded from the bodies and the counts by default"),
|
|
153
169
|
},
|
|
154
170
|
write: false,
|
|
155
|
-
handler: async ({ hours, filter }, wa) => {
|
|
156
|
-
const result = await wa.getRecentMessages(hours, filter);
|
|
171
|
+
handler: async ({ hours, filter, include_system }, wa) => {
|
|
172
|
+
const result = await wa.getRecentMessages(hours, filter, include_system);
|
|
157
173
|
const messageCount = result.data.reduce((n, c) => n + c.messages.length, 0);
|
|
158
174
|
return ok(renderConversations(result.data, hours), synced(result, {
|
|
159
175
|
hours,
|
|
160
176
|
filter,
|
|
177
|
+
include_system,
|
|
161
178
|
conversation_count: result.data.length,
|
|
162
179
|
message_count: messageCount,
|
|
163
180
|
conversations: result.data,
|
|
@@ -208,6 +225,29 @@ on the number). Returns contact_id values usable as chat_id.`,
|
|
|
208
225
|
return ok(renderContacts(query, contacts), { query, count: contacts.length, contacts });
|
|
209
226
|
},
|
|
210
227
|
}),
|
|
228
|
+
tool({
|
|
229
|
+
name: "sync_contacts",
|
|
230
|
+
title: "Fetch the phone's address book again",
|
|
231
|
+
description: `Ask WhatsApp to send the linked phone's address book from scratch, and wait up
|
|
232
|
+
to 15 seconds for it. Nothing on WhatsApp changes: this only refills wazap's
|
|
233
|
+
own contact list.
|
|
234
|
+
|
|
235
|
+
Use it when get_status reports contacts_named: 0, or when senders in a group
|
|
236
|
+
read as phone numbers for people you know are saved on the phone. Returns
|
|
237
|
+
named_before and named_after so you can tell whether it helped; if both are 0
|
|
238
|
+
the phone has no saved contacts for these people.`,
|
|
239
|
+
schema: {},
|
|
240
|
+
write: false,
|
|
241
|
+
handler: async (_args, wa) => {
|
|
242
|
+
const result = await wa.syncContacts();
|
|
243
|
+
const text = result.named_after > result.named_before
|
|
244
|
+
? `Address book synced: ${result.named_after} named contacts (was ${result.named_before}).`
|
|
245
|
+
: result.named_after > 0
|
|
246
|
+
? `Address book already current: ${result.named_after} named contacts.`
|
|
247
|
+
: "WhatsApp sent no names at all; the phone has no saved contacts for these people.";
|
|
248
|
+
return ok(text, result);
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
211
251
|
tool({
|
|
212
252
|
name: "get_contact",
|
|
213
253
|
title: "Get WhatsApp contact details",
|
package/dist/whatsapp.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
|
-
import
|
|
10
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
11
|
+
import makeWASocket, { ALL_WA_PATCH_NAMES, Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
|
|
11
12
|
import { readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
12
13
|
import { BAILEYS_VERSION, paths, WAZAP_VERSION } from "./config.js";
|
|
13
14
|
import { asWazapError, RELINK_FIX, RESET_FIX, WazapError } from "./errors.js";
|
|
14
|
-
import { isGroupId, resolveChatId } from "./ids.js";
|
|
15
|
+
import { isGroupId, isNoiseJid, resolveChatId } from "./ids.js";
|
|
15
16
|
import { log, logError } from "./logger.js";
|
|
16
|
-
import { buildMessageView, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
|
|
17
|
+
import { buildMessageView, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
|
|
17
18
|
/** Reconnect pacing. A closed socket used to be retried instantly, which turns
|
|
18
19
|
* any persistent rejection into a login storm — WhatsApp answers that by
|
|
19
20
|
* throttling the account and refusing to link *any* new device to it, phone
|
|
@@ -37,6 +38,37 @@ const STORE_SAVE_DEBOUNCE_MS = 20_000;
|
|
|
37
38
|
const HISTORY_STORE_CAP_PER_CHAT = 2_000;
|
|
38
39
|
const DIR_MODE = 0o700;
|
|
39
40
|
const FILE_MODE = 0o600;
|
|
41
|
+
/**
|
|
42
|
+
* A contact WhatsApp will not name for us still arrives with a `name`: the
|
|
43
|
+
* masked number "+40∙∙∙∙∙∙∙98". Counting those as address-book entries would
|
|
44
|
+
* make wazap believe the address book had landed, and showing one hides the
|
|
45
|
+
* plain number the reader can actually dial. Anything made only of digits and
|
|
46
|
+
* masking is not a name.
|
|
47
|
+
*/
|
|
48
|
+
const NOT_A_NAME = /^[+\d\s()\-.·•∙…*]+$/u;
|
|
49
|
+
/** The name a human wrote, or "" for a placeholder and for nothing at all. */
|
|
50
|
+
export function realName(value) {
|
|
51
|
+
const name = value?.trim() ?? "";
|
|
52
|
+
return name === "" || NOT_A_NAME.test(name) ? "" : name;
|
|
53
|
+
}
|
|
54
|
+
/** A resync asks WhatsApp for the whole address book, so it is not free. */
|
|
55
|
+
const CONTACT_RESYNC_COOLDOWN_MS = 7 * 24 * 3_600_000;
|
|
56
|
+
/** How long past the initial sync a slow app state sync still gets to deliver. */
|
|
57
|
+
const CONTACT_SETTLE_MS = 15_000;
|
|
58
|
+
/**
|
|
59
|
+
* Whether this session should ask WhatsApp for the address book from scratch.
|
|
60
|
+
*
|
|
61
|
+
* Names arrive only in an app state sync that starts from version zero. With no
|
|
62
|
+
* stored version there is nothing to heal: the connection is already doing that
|
|
63
|
+
* sync. With versions stored and no names in hand, the delivery went somewhere
|
|
64
|
+
* that threw it away, and only a resync gets it back. An account whose address
|
|
65
|
+
* book is genuinely empty looks identical, which is what the cooldown is for.
|
|
66
|
+
*/
|
|
67
|
+
export function needsContactResync({ named, storedVersions, resyncedAt, now }) {
|
|
68
|
+
if (named > 0 || !storedVersions)
|
|
69
|
+
return false;
|
|
70
|
+
return resyncedAt === null || now - resyncedAt >= CONTACT_RESYNC_COOLDOWN_MS;
|
|
71
|
+
}
|
|
40
72
|
/** Baileys logs at info level to stdout by default, which corrupts the MCP
|
|
41
73
|
* JSON-RPC stream on stdio. */
|
|
42
74
|
const silentLogger = {
|
|
@@ -57,6 +89,14 @@ class Store {
|
|
|
57
89
|
byChat = new Map();
|
|
58
90
|
edited = new Set();
|
|
59
91
|
reactions = new Map();
|
|
92
|
+
/**
|
|
93
|
+
* The name a sender publishes on their own profile, as WhatsApp attaches it
|
|
94
|
+
* to their messages. It is the only name we get for someone the user has not
|
|
95
|
+
* saved, and it never arrives through the contact list.
|
|
96
|
+
*/
|
|
97
|
+
pushNames = new Map();
|
|
98
|
+
/** See `needsContactResync`: it keeps a full resync from repeating forever. */
|
|
99
|
+
contactsResyncedAt = null;
|
|
60
100
|
seconds(sid) {
|
|
61
101
|
const raw = this.messages.get(sid);
|
|
62
102
|
return raw ? messageTimestampMs(raw) / 1000 : 0;
|
|
@@ -96,7 +136,15 @@ class Store {
|
|
|
96
136
|
return [...map].map(([sender, emoji]) => ({ emoji, sender }));
|
|
97
137
|
}
|
|
98
138
|
serialize() {
|
|
99
|
-
const snapshot = {
|
|
139
|
+
const snapshot = {
|
|
140
|
+
v: 1,
|
|
141
|
+
chats: {},
|
|
142
|
+
contacts: {},
|
|
143
|
+
messages: {},
|
|
144
|
+
byChat: {},
|
|
145
|
+
pushNames: Object.fromEntries(this.pushNames),
|
|
146
|
+
...(this.contactsResyncedAt === null ? {} : { contactsResyncedAt: this.contactsResyncedAt }),
|
|
147
|
+
};
|
|
100
148
|
for (const [jid, chat] of this.chats) {
|
|
101
149
|
const encoded = encode(() => proto.Conversation.encode(chat).finish());
|
|
102
150
|
if (encoded)
|
|
@@ -121,22 +169,30 @@ class Store {
|
|
|
121
169
|
}
|
|
122
170
|
return snapshot;
|
|
123
171
|
}
|
|
172
|
+
/** A snapshot an older wazap wrote can still hold noise it used to keep. */
|
|
124
173
|
hydrate(snapshot) {
|
|
125
174
|
if (snapshot?.v !== 1)
|
|
126
175
|
return;
|
|
127
176
|
for (const [jid, b64] of Object.entries(snapshot.chats ?? {})) {
|
|
177
|
+
if (isNoiseJid(jid))
|
|
178
|
+
continue;
|
|
128
179
|
const chat = decodeChat(b64);
|
|
129
180
|
if (chat)
|
|
130
181
|
this.chats.set(jid, chat);
|
|
131
182
|
}
|
|
132
183
|
for (const [jid, contact] of Object.entries(snapshot.contacts ?? {}))
|
|
133
184
|
this.contacts.set(jid, contact);
|
|
185
|
+
for (const [jid, name] of Object.entries(snapshot.pushNames ?? {}))
|
|
186
|
+
this.pushNames.set(jid, name);
|
|
187
|
+
this.contactsResyncedAt = snapshot.contactsResyncedAt ?? null;
|
|
134
188
|
for (const [sid, b64] of Object.entries(snapshot.messages ?? {})) {
|
|
135
189
|
const raw = decodeMessage(b64);
|
|
136
|
-
if (raw)
|
|
190
|
+
if (raw && !isControlMessage(raw))
|
|
137
191
|
this.messages.set(sid, raw);
|
|
138
192
|
}
|
|
139
193
|
for (const [jid, ring] of Object.entries(snapshot.byChat ?? {})) {
|
|
194
|
+
if (isNoiseJid(jid))
|
|
195
|
+
continue;
|
|
140
196
|
const present = ring.filter((sid) => this.messages.has(sid));
|
|
141
197
|
this.byChat.set(jid, present);
|
|
142
198
|
for (const sid of present)
|
|
@@ -172,10 +228,15 @@ export class WhatsAppService {
|
|
|
172
228
|
storeDirty = false;
|
|
173
229
|
storeSaveTimer = null;
|
|
174
230
|
persistedLoaded = false;
|
|
231
|
+
contactResyncTried = false;
|
|
175
232
|
blocked = new Set();
|
|
176
233
|
groupCache = new Map();
|
|
234
|
+
/** Groups whose metadata WhatsApp refused, so we stop asking on every read. */
|
|
235
|
+
unreadableGroups = new Set();
|
|
177
236
|
/** `<user>@lid` to the phone-number jid, so ids we hand out stay canonical. */
|
|
178
237
|
lidToPn = new Map();
|
|
238
|
+
/** The same, for naming only, and it holds more. See `learnLidPhone`. */
|
|
239
|
+
lidPhones = new Map();
|
|
179
240
|
store = new Store();
|
|
180
241
|
paths;
|
|
181
242
|
constructor(config) {
|
|
@@ -236,8 +297,35 @@ export class WhatsAppService {
|
|
|
236
297
|
hasHistory() {
|
|
237
298
|
return this.historyReceived;
|
|
238
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* The same full resync the self-heal runs, on demand. Nothing about the
|
|
302
|
+
* account changes: this asks WhatsApp to send the address book again.
|
|
303
|
+
*/
|
|
304
|
+
syncContacts() {
|
|
305
|
+
return this.guarded(async () => {
|
|
306
|
+
const sock = this.ensureConnected();
|
|
307
|
+
const before = this.namedContacts();
|
|
308
|
+
await this.resyncContacts(sock);
|
|
309
|
+
const after = await this.waitForNames(before, Date.now() + CONTACT_SETTLE_MS);
|
|
310
|
+
return { requested: true, named_before: before, named_after: after };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
239
313
|
storeCounts() {
|
|
240
|
-
return { chats: this.store.chats.size, contacts: this.
|
|
314
|
+
return { chats: this.store.chats.size, contacts: this.namedContacts(), messages: this.store.messages.size };
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* People from the phone's address book: the only contact count worth
|
|
318
|
+
* reporting. The store also holds everyone who ever appeared in a group and
|
|
319
|
+
* every group itself, so its raw size says nothing about whether the address
|
|
320
|
+
* book ever arrived.
|
|
321
|
+
*/
|
|
322
|
+
namedContacts() {
|
|
323
|
+
let named = 0;
|
|
324
|
+
for (const [jid, contact] of this.store.contacts) {
|
|
325
|
+
if (!isGroupId(jid) && realName(contact.name))
|
|
326
|
+
named++;
|
|
327
|
+
}
|
|
328
|
+
return named;
|
|
241
329
|
}
|
|
242
330
|
getStatus() {
|
|
243
331
|
const info = {
|
|
@@ -248,6 +336,7 @@ export class WhatsAppService {
|
|
|
248
336
|
reconnect_attempts: this.reconnectAttempts,
|
|
249
337
|
wazap_version: WAZAP_VERSION,
|
|
250
338
|
baileys_version: BAILEYS_VERSION,
|
|
339
|
+
contacts_named: this.namedContacts(),
|
|
251
340
|
data_dir: this.config.dataDir,
|
|
252
341
|
read_only: this.config.readOnly,
|
|
253
342
|
rate_limit: this.config.rateLimitPerMinute,
|
|
@@ -263,11 +352,12 @@ export class WhatsAppService {
|
|
|
263
352
|
return this.guarded(async () => {
|
|
264
353
|
this.ensureConnected();
|
|
265
354
|
await this.waitForSync();
|
|
266
|
-
const
|
|
355
|
+
const shown = this.knownChats()
|
|
267
356
|
.filter((chat) => this.matchesChatFilter(chat, filter))
|
|
268
357
|
.sort((a, b) => this.chatActivity(b) - this.chatActivity(a))
|
|
269
|
-
.slice(0, limit)
|
|
270
|
-
|
|
358
|
+
.slice(0, limit);
|
|
359
|
+
await this.learnLidPhones(shown.map((chat) => this.canonical(chat.id ?? "")));
|
|
360
|
+
const chats = shown.map((chat) => this.chatSummary(chat));
|
|
271
361
|
return this.synced(chats);
|
|
272
362
|
});
|
|
273
363
|
}
|
|
@@ -276,6 +366,8 @@ export class WhatsAppService {
|
|
|
276
366
|
const sock = this.ensureConnected();
|
|
277
367
|
const jid = this.resolveId(chatId);
|
|
278
368
|
await this.waitForSync();
|
|
369
|
+
await this.learnParticipants(jid);
|
|
370
|
+
await this.learnLidPhones([jid]);
|
|
279
371
|
if (before === undefined) {
|
|
280
372
|
const ring = this.store.byChat.get(jid) ?? [];
|
|
281
373
|
return this.synced(this.viewsFor(ring.slice(-limit), jid));
|
|
@@ -289,13 +381,16 @@ export class WhatsAppService {
|
|
|
289
381
|
return this.synced(this.viewsFor(older, jid));
|
|
290
382
|
});
|
|
291
383
|
}
|
|
292
|
-
getRecentMessages(hours, filter) {
|
|
384
|
+
getRecentMessages(hours, filter, includeSystem = false) {
|
|
293
385
|
return this.guarded(async () => {
|
|
294
386
|
this.ensureConnected();
|
|
295
387
|
await this.waitForSync();
|
|
296
388
|
const cutoff = Date.now() - hours * 3_600_000;
|
|
297
389
|
const conversations = [];
|
|
390
|
+
await this.learnLidPhones(this.store.byChat.keys());
|
|
298
391
|
for (const [jid, ring] of this.store.byChat) {
|
|
392
|
+
if (isNoiseJid(jid))
|
|
393
|
+
continue;
|
|
299
394
|
const chat = this.store.chats.get(jid);
|
|
300
395
|
if (chat && !this.matchesChatFilter(chat, filter))
|
|
301
396
|
continue;
|
|
@@ -307,13 +402,14 @@ export class WhatsAppService {
|
|
|
307
402
|
});
|
|
308
403
|
if (recent.length === 0)
|
|
309
404
|
continue;
|
|
310
|
-
const messages = this.viewsFor(recent, jid);
|
|
311
|
-
|
|
405
|
+
const messages = this.viewsFor(recent, jid).filter((view) => includeSystem || view.type !== "system");
|
|
406
|
+
if (messages.length === 0)
|
|
407
|
+
continue;
|
|
312
408
|
conversations.push({
|
|
313
409
|
chat_id: jid,
|
|
314
|
-
chat_name: this.
|
|
410
|
+
chat_name: this.displayName(jid),
|
|
315
411
|
type: isGroupId(jid) ? "group" : "individual",
|
|
316
|
-
last_activity:
|
|
412
|
+
last_activity: messages[messages.length - 1].timestamp,
|
|
317
413
|
messages,
|
|
318
414
|
});
|
|
319
415
|
}
|
|
@@ -355,12 +451,12 @@ export class WhatsAppService {
|
|
|
355
451
|
const digits = needle.replace(/\D/g, "");
|
|
356
452
|
const matches = [];
|
|
357
453
|
for (const [jid, contact] of this.store.contacts) {
|
|
358
|
-
|
|
359
|
-
|
|
454
|
+
// Every name we might show, or someone the chat list calls "Carmen"
|
|
455
|
+
// would not be findable by that name here.
|
|
456
|
+
const known = [contact.name, contact.verifiedName, contact.notify, this.store.pushNames.get(jid)].map(realName);
|
|
360
457
|
const number = jid.split("@")[0] ?? "";
|
|
361
458
|
const hit = needle === "" ||
|
|
362
|
-
name.includes(needle) ||
|
|
363
|
-
notify.includes(needle) ||
|
|
459
|
+
known.some((name) => name?.toLowerCase().includes(needle)) ||
|
|
364
460
|
(digits.length >= 5 && number.includes(digits));
|
|
365
461
|
if (!hit)
|
|
366
462
|
continue;
|
|
@@ -413,7 +509,7 @@ export class WhatsAppService {
|
|
|
413
509
|
participant_count: meta.participants.length,
|
|
414
510
|
participants: meta.participants.slice(0, MAX_GROUP_PARTICIPANTS).map((p) => {
|
|
415
511
|
const id = this.canonical(p.id);
|
|
416
|
-
return { contact_id: id, name: this.
|
|
512
|
+
return { contact_id: id, name: this.displayName(id), is_admin: isAdmin(p) };
|
|
417
513
|
}),
|
|
418
514
|
announcement_only: Boolean(meta.announce),
|
|
419
515
|
i_am_admin: iAmAdmin,
|
|
@@ -588,7 +684,7 @@ export class WhatsAppService {
|
|
|
588
684
|
const sock = this.beginWrite();
|
|
589
685
|
const ids = participantIds.map((id) => this.resolveId(id));
|
|
590
686
|
const meta = await sock.groupCreate(name, ids);
|
|
591
|
-
this.
|
|
687
|
+
this.cacheGroup(this.canonical(meta.id), meta);
|
|
592
688
|
const present = new Set(meta.participants.map((p) => this.canonical(p.id)));
|
|
593
689
|
return {
|
|
594
690
|
chat_id: this.canonical(meta.id),
|
|
@@ -710,6 +806,7 @@ export class WhatsAppService {
|
|
|
710
806
|
this.adoptSocketAccount();
|
|
711
807
|
this.armSyncDeadline();
|
|
712
808
|
log("connected to WhatsApp");
|
|
809
|
+
void this.healContacts(sock, generation);
|
|
713
810
|
}
|
|
714
811
|
else if (connection === "close") {
|
|
715
812
|
const code = statusCodeOf(lastDisconnect?.error);
|
|
@@ -752,6 +849,8 @@ export class WhatsAppService {
|
|
|
752
849
|
if (!update.id)
|
|
753
850
|
continue;
|
|
754
851
|
const jid = this.canonical(update.id);
|
|
852
|
+
if (isNoiseJid(jid))
|
|
853
|
+
continue;
|
|
755
854
|
const previous = this.store.chats.get(jid);
|
|
756
855
|
this.store.chats.set(jid, { ...(previous ?? {}), ...update, id: jid });
|
|
757
856
|
}
|
|
@@ -773,6 +872,7 @@ export class WhatsAppService {
|
|
|
773
872
|
const previous = this.store.contacts.get(this.canonical(update.id));
|
|
774
873
|
this.ingestContact({ ...(previous ?? {}), ...update, id: update.id });
|
|
775
874
|
}
|
|
875
|
+
this.markStoreDirty();
|
|
776
876
|
});
|
|
777
877
|
sock.ev.on("messages.upsert", ({ messages, type }) => {
|
|
778
878
|
const stored = this.ingestMessages(messages);
|
|
@@ -829,7 +929,7 @@ export class WhatsAppService {
|
|
|
829
929
|
});
|
|
830
930
|
sock.ev.on("groups.upsert", (groups) => {
|
|
831
931
|
for (const meta of groups)
|
|
832
|
-
this.
|
|
932
|
+
this.cacheGroup(this.canonical(meta.id), meta);
|
|
833
933
|
});
|
|
834
934
|
sock.ev.on("groups.update", (updates) => {
|
|
835
935
|
for (const update of updates) {
|
|
@@ -947,6 +1047,66 @@ export class WhatsAppService {
|
|
|
947
1047
|
this.syncWaiters.push(waiter);
|
|
948
1048
|
});
|
|
949
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The address book, once, for a session that connected without it.
|
|
1052
|
+
*
|
|
1053
|
+
* Names reach a companion through the app state sync, and WhatsApp sends each
|
|
1054
|
+
* collection's snapshot only to a connection asking from version zero. A
|
|
1055
|
+
* socket that saved those versions and dropped the contacts leaves every later
|
|
1056
|
+
* connection resyncing from a version with nothing left to send, so the only
|
|
1057
|
+
* way back is to forget the versions and ask again.
|
|
1058
|
+
*/
|
|
1059
|
+
async healContacts(sock, generation) {
|
|
1060
|
+
if (this.contactResyncTried)
|
|
1061
|
+
return;
|
|
1062
|
+
this.contactResyncTried = true;
|
|
1063
|
+
try {
|
|
1064
|
+
await this.waitForSync();
|
|
1065
|
+
const named = await this.waitForNames(0, Date.now() + CONTACT_SETTLE_MS);
|
|
1066
|
+
if (generation !== this.generation || this.stopped)
|
|
1067
|
+
return;
|
|
1068
|
+
const decision = {
|
|
1069
|
+
named,
|
|
1070
|
+
storedVersions: await this.hasAppStateVersions(sock),
|
|
1071
|
+
resyncedAt: this.store.contactsResyncedAt,
|
|
1072
|
+
now: Date.now(),
|
|
1073
|
+
};
|
|
1074
|
+
if (!needsContactResync(decision))
|
|
1075
|
+
return;
|
|
1076
|
+
log("address book missing; requesting a full contact sync");
|
|
1077
|
+
await this.resyncContacts(sock);
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
logError("contact sync", err);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/** Names still arriving mean the sync is working; only silence means it is not coming. */
|
|
1084
|
+
async waitForNames(floor, deadline) {
|
|
1085
|
+
for (;;) {
|
|
1086
|
+
const named = this.namedContacts();
|
|
1087
|
+
if (named > floor || this.stopped || Date.now() >= deadline)
|
|
1088
|
+
return named;
|
|
1089
|
+
await sleep(500);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
async hasAppStateVersions(sock) {
|
|
1093
|
+
const stored = await sock.authState.keys.get("app-state-sync-version", [...ALL_WA_PATCH_NAMES]);
|
|
1094
|
+
return Object.values(stored).some((state) => state);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Forget every stored app state version, then resync. The order is the whole
|
|
1098
|
+
* point: Baileys asks for a snapshot only when it has no version to resume
|
|
1099
|
+
* from, and the snapshot is what carries the contacts. The timestamp is
|
|
1100
|
+
* written before the request, so a resync interrupted halfway is not retried
|
|
1101
|
+
* on every start.
|
|
1102
|
+
*/
|
|
1103
|
+
async resyncContacts(sock) {
|
|
1104
|
+
const forgotten = Object.fromEntries(ALL_WA_PATCH_NAMES.map((name) => [name, null]));
|
|
1105
|
+
await sock.authState.keys.set({ "app-state-sync-version": forgotten });
|
|
1106
|
+
this.store.contactsResyncedAt = Date.now();
|
|
1107
|
+
this.markStoreDirty();
|
|
1108
|
+
await sock.resyncAppState(ALL_WA_PATCH_NAMES, true);
|
|
1109
|
+
}
|
|
950
1110
|
syncState() {
|
|
951
1111
|
return this.initialSyncDone ? "done" : "in_progress";
|
|
952
1112
|
}
|
|
@@ -1026,9 +1186,25 @@ export class WhatsAppService {
|
|
|
1026
1186
|
throw new WazapError("GROUP_NOT_FOUND", `WhatsApp does not know the group ${jid}.`);
|
|
1027
1187
|
throw new WazapError("GROUP_NOT_FOUND", `Could not read ${jid}: ${describe(err)}`);
|
|
1028
1188
|
}
|
|
1029
|
-
this.
|
|
1189
|
+
this.cacheGroup(jid, meta);
|
|
1030
1190
|
return meta;
|
|
1031
1191
|
}
|
|
1192
|
+
cacheGroup(jid, meta) {
|
|
1193
|
+
this.groupCache.set(jid, meta);
|
|
1194
|
+
this.learnGroup(meta);
|
|
1195
|
+
this.markStoreDirty();
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Reading a group for the first time costs one metadata fetch, after which its
|
|
1199
|
+
* senders resolve from cache. A group we cannot read — left, deleted — is not
|
|
1200
|
+
* worth failing the read over, and asking again on every read would cost a
|
|
1201
|
+
* round trip per message page forever.
|
|
1202
|
+
*/
|
|
1203
|
+
async learnParticipants(jid) {
|
|
1204
|
+
if (!isGroupId(jid) || this.groupCache.has(jid) || this.unreadableGroups.has(jid))
|
|
1205
|
+
return;
|
|
1206
|
+
await this.groupMeta(jid).catch(() => this.unreadableGroups.add(jid));
|
|
1207
|
+
}
|
|
1032
1208
|
myParticipation(meta) {
|
|
1033
1209
|
return meta.participants.find((p) => this.isMe(p.id) || (p.phoneNumber && this.isMe(p.phoneNumber)));
|
|
1034
1210
|
}
|
|
@@ -1072,24 +1248,94 @@ export class WhatsAppService {
|
|
|
1072
1248
|
return jid;
|
|
1073
1249
|
}
|
|
1074
1250
|
}
|
|
1251
|
+
/**
|
|
1252
|
+
* WhatsApp usually keys a contact by its phone jid and names the LID on the
|
|
1253
|
+
* side, leaving `phoneNumber` empty, so the pairing has to be read off `id`.
|
|
1254
|
+
* A hydrated store is full of these, which is why loading one relearns them.
|
|
1255
|
+
*/
|
|
1256
|
+
relearnLid(contact) {
|
|
1257
|
+
if (!contact.lid)
|
|
1258
|
+
return;
|
|
1259
|
+
if (contact.phoneNumber)
|
|
1260
|
+
this.learnLid(contact.lid, contact.phoneNumber);
|
|
1261
|
+
else if (contact.id?.endsWith("@s.whatsapp.net"))
|
|
1262
|
+
this.learnLidPhone(contact.lid, contact.id);
|
|
1263
|
+
}
|
|
1264
|
+
/** A pairing WhatsApp stated in a field meant for it, so ids may follow it. */
|
|
1075
1265
|
learnLid(lid, pn) {
|
|
1076
1266
|
if (!lid || !pn)
|
|
1077
1267
|
return;
|
|
1078
|
-
|
|
1079
|
-
this.
|
|
1268
|
+
this.lidToPn.set(lidKey(lid), jidNormalizedUser(pn));
|
|
1269
|
+
this.learnLidPhone(lid, pn);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* A pairing wazap inferred or looked up. It names people and never renames a
|
|
1273
|
+
* chat: a chat whose history is filed under a LID would split in two the
|
|
1274
|
+
* moment its id started canonicalising to the number instead, and the older
|
|
1275
|
+
* half would stop being reachable by any id at all.
|
|
1276
|
+
*/
|
|
1277
|
+
learnLidPhone(lid, pn) {
|
|
1278
|
+
if (!lid || !pn)
|
|
1279
|
+
return;
|
|
1280
|
+
const key = lidKey(lid);
|
|
1281
|
+
const phone = jidNormalizedUser(pn);
|
|
1282
|
+
this.lidPhones.set(key, phone);
|
|
1283
|
+
const pushed = this.store.pushNames.get(key) ?? this.store.pushNames.get(phone);
|
|
1284
|
+
if (pushed) {
|
|
1285
|
+
this.store.pushNames.set(key, pushed);
|
|
1286
|
+
this.store.pushNames.set(phone, pushed);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Ask Baileys for the numbers behind the LIDs we are about to name. It answers
|
|
1291
|
+
* from the table the account has already synced, so this is a lookup and not a
|
|
1292
|
+
* fetch, and it covers LIDs no chat, contact or group ever paired.
|
|
1293
|
+
*/
|
|
1294
|
+
async learnLidPhones(jids) {
|
|
1295
|
+
const missing = [...new Set(jids)].filter((jid) => jid.endsWith("@lid") && !this.lidPhones.has(jid));
|
|
1296
|
+
if (missing.length === 0)
|
|
1297
|
+
return;
|
|
1298
|
+
const mappings = await this.sockClient?.signalRepository.lidMapping
|
|
1299
|
+
.getPNsForLIDs(missing)
|
|
1300
|
+
.catch(() => null);
|
|
1301
|
+
for (const { lid, pn } of mappings ?? [])
|
|
1302
|
+
this.learnLidPhone(lid, pn);
|
|
1080
1303
|
}
|
|
1081
|
-
|
|
1304
|
+
/**
|
|
1305
|
+
* The one place a jid becomes a name, so a sender, a chat header, a digest
|
|
1306
|
+
* title and a participant list can never disagree. The last rung is never a
|
|
1307
|
+
* raw LID: a LID is fifteen digits that read as a phone number and are not
|
|
1308
|
+
* one, so an unresolved one says it is unknown instead.
|
|
1309
|
+
*
|
|
1310
|
+
* `hint` is the pushName on the message being rendered, for a sender whose
|
|
1311
|
+
* name has not been ingested yet.
|
|
1312
|
+
*/
|
|
1313
|
+
displayName(jid, hint) {
|
|
1314
|
+
if (!jid)
|
|
1315
|
+
return "unknown";
|
|
1082
1316
|
if (this.isMe(jid))
|
|
1083
1317
|
return this.account?.name || "You";
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1318
|
+
if (isGroupId(jid)) {
|
|
1319
|
+
return this.store.chats.get(jid)?.name || this.groupCache.get(jid)?.subject || jid;
|
|
1320
|
+
}
|
|
1321
|
+
const phoneJid = jid.endsWith("@lid") ? this.lidPhones.get(jid) : undefined;
|
|
1322
|
+
for (const known of phoneJid ? [jid, phoneJid] : [jid]) {
|
|
1323
|
+
const contact = this.store.contacts.get(known);
|
|
1324
|
+
const name = realName(contact?.name) ||
|
|
1325
|
+
realName(contact?.verifiedName) ||
|
|
1326
|
+
realName(contact?.notify) ||
|
|
1327
|
+
realName(this.store.pushNames.get(known)) ||
|
|
1328
|
+
realName(this.store.chats.get(known)?.name);
|
|
1329
|
+
if (name)
|
|
1330
|
+
return name;
|
|
1331
|
+
}
|
|
1332
|
+
const hinted = realName(hint);
|
|
1333
|
+
if (hinted)
|
|
1334
|
+
return hinted;
|
|
1335
|
+
const digits = (phoneJid ?? jid).split("@")[0] ?? "";
|
|
1336
|
+
if ((phoneJid ?? jid).endsWith("@s.whatsapp.net"))
|
|
1337
|
+
return digits;
|
|
1338
|
+
return jid.endsWith("@lid") ? `unknown (lid …${digits.slice(-4)})` : jid;
|
|
1093
1339
|
}
|
|
1094
1340
|
messageOrThrow(messageId) {
|
|
1095
1341
|
const raw = this.store.messages.get(messageId);
|
|
@@ -1108,7 +1354,7 @@ export class WhatsAppService {
|
|
|
1108
1354
|
const raw = this.messageOrThrow(sid);
|
|
1109
1355
|
return buildMessageView(raw, {
|
|
1110
1356
|
canonical: (jid) => this.canonical(jid),
|
|
1111
|
-
nameFor: (jid) => this.
|
|
1357
|
+
nameFor: (jid) => this.displayName(jid, raw.pushName ?? undefined),
|
|
1112
1358
|
ownId: this.ownJid(),
|
|
1113
1359
|
chatId: chatJid,
|
|
1114
1360
|
edited: this.store.edited.has(sid),
|
|
@@ -1152,7 +1398,7 @@ export class WhatsAppService {
|
|
|
1152
1398
|
knownChats() {
|
|
1153
1399
|
const chats = [...this.store.chats.values()];
|
|
1154
1400
|
for (const jid of this.store.byChat.keys()) {
|
|
1155
|
-
if (!this.store.chats.has(jid))
|
|
1401
|
+
if (!this.store.chats.has(jid) && !isNoiseJid(jid))
|
|
1156
1402
|
chats.push({ id: jid });
|
|
1157
1403
|
}
|
|
1158
1404
|
return chats;
|
|
@@ -1186,7 +1432,7 @@ export class WhatsAppService {
|
|
|
1186
1432
|
const muteEnd = protoNumber(chat.muteEndTime) ?? 0;
|
|
1187
1433
|
const summary = {
|
|
1188
1434
|
chat_id: jid,
|
|
1189
|
-
name:
|
|
1435
|
+
name: this.displayName(jid),
|
|
1190
1436
|
type: isGroupId(jid) ? "group" : "individual",
|
|
1191
1437
|
unread_count: Math.max(0, chat.unreadCount ?? 0),
|
|
1192
1438
|
last_message: last
|
|
@@ -1206,12 +1452,13 @@ export class WhatsAppService {
|
|
|
1206
1452
|
return summary;
|
|
1207
1453
|
}
|
|
1208
1454
|
contactSummary(jid, contact) {
|
|
1209
|
-
const
|
|
1455
|
+
const phoneJid = jid.endsWith("@lid") ? (this.lidPhones.get(jid) ?? jid) : jid;
|
|
1456
|
+
const number = phoneJid.endsWith("@s.whatsapp.net") ? (phoneJid.split("@")[0] ?? null) : null;
|
|
1210
1457
|
return {
|
|
1211
1458
|
contact_id: jid,
|
|
1212
|
-
name:
|
|
1459
|
+
name: this.displayName(jid),
|
|
1213
1460
|
number,
|
|
1214
|
-
is_my_contact:
|
|
1461
|
+
is_my_contact: realName(contact?.name) !== "",
|
|
1215
1462
|
is_business: Boolean(contact?.verifiedName),
|
|
1216
1463
|
};
|
|
1217
1464
|
}
|
|
@@ -1230,14 +1477,15 @@ export class WhatsAppService {
|
|
|
1230
1477
|
if (chat.lidJid && chat.pnJid)
|
|
1231
1478
|
this.learnLid(chat.lidJid, chat.pnJid);
|
|
1232
1479
|
const jid = this.canonical(chat.id);
|
|
1480
|
+
if (isNoiseJid(jid))
|
|
1481
|
+
return;
|
|
1233
1482
|
const previous = this.store.chats.get(jid);
|
|
1234
1483
|
this.store.chats.set(jid, { ...(previous ?? {}), ...chat, id: jid });
|
|
1235
1484
|
}
|
|
1236
1485
|
ingestContact(contact) {
|
|
1237
1486
|
if (!contact.id)
|
|
1238
1487
|
return;
|
|
1239
|
-
|
|
1240
|
-
this.learnLid(contact.lid, contact.phoneNumber);
|
|
1488
|
+
this.relearnLid(contact);
|
|
1241
1489
|
const jid = this.canonical(contact.id);
|
|
1242
1490
|
const previous = this.store.contacts.get(jid);
|
|
1243
1491
|
this.store.contacts.set(jid, { ...(previous ?? {}), ...contact, id: jid });
|
|
@@ -1245,16 +1493,41 @@ export class WhatsAppService {
|
|
|
1245
1493
|
ingestMessages(messages) {
|
|
1246
1494
|
const stored = [];
|
|
1247
1495
|
for (const raw of messages) {
|
|
1248
|
-
if (!raw.
|
|
1496
|
+
if (!raw.key?.remoteJid || (!raw.message && !isStubEvent(raw)))
|
|
1249
1497
|
continue;
|
|
1250
1498
|
const jid = this.canonical(raw.key.remoteJid);
|
|
1251
|
-
if (jid
|
|
1499
|
+
if (isNoiseJid(jid) || isControlMessage(raw))
|
|
1252
1500
|
continue;
|
|
1501
|
+
this.learnPushName(raw, jid);
|
|
1253
1502
|
this.store.putMessage(messageIdFor(raw.key, jid), jid, raw);
|
|
1254
1503
|
stored.push(raw);
|
|
1255
1504
|
}
|
|
1256
1505
|
return stored;
|
|
1257
1506
|
}
|
|
1507
|
+
learnPushName(raw, chatJid) {
|
|
1508
|
+
const name = raw.pushName?.trim();
|
|
1509
|
+
if (!name || raw.key.fromMe)
|
|
1510
|
+
return;
|
|
1511
|
+
const sender = this.canonical(raw.key.participant ?? raw.participant ?? chatJid);
|
|
1512
|
+
if (sender && !this.isMe(sender))
|
|
1513
|
+
this.store.pushNames.set(sender, name);
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* One fetch teaches every later message in that group who its participants
|
|
1517
|
+
* are, which matters most for a group whose members are strangers to the
|
|
1518
|
+
* address book.
|
|
1519
|
+
*/
|
|
1520
|
+
learnGroup(meta) {
|
|
1521
|
+
for (const p of meta.participants) {
|
|
1522
|
+
const lid = p.lid ?? (p.id.endsWith("@lid") ? p.id : undefined);
|
|
1523
|
+
const phone = p.phoneNumber ?? (p.id.endsWith("@s.whatsapp.net") ? p.id : undefined);
|
|
1524
|
+
if (lid && phone)
|
|
1525
|
+
this.learnLidPhone(lid, phone);
|
|
1526
|
+
const name = p.name ?? p.notify ?? p.username;
|
|
1527
|
+
if (name)
|
|
1528
|
+
this.ingestContact({ id: phone ?? p.id, ...(lid ? { lid } : {}), notify: name });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1258
1531
|
async loadPersisted() {
|
|
1259
1532
|
if (!this.config.persistHistory || this.persistedLoaded)
|
|
1260
1533
|
return;
|
|
@@ -1266,6 +1539,8 @@ export class WhatsAppService {
|
|
|
1266
1539
|
try {
|
|
1267
1540
|
const text = await readFile(this.paths.storeFile, "utf8");
|
|
1268
1541
|
this.store.hydrate(JSON.parse(text));
|
|
1542
|
+
for (const contact of this.store.contacts.values())
|
|
1543
|
+
this.relearnLid(contact);
|
|
1269
1544
|
log(`store loaded: ${this.store.chats.size} chats, ${this.store.messages.size} messages`);
|
|
1270
1545
|
}
|
|
1271
1546
|
catch (err) {
|
|
@@ -1344,9 +1619,12 @@ export class WhatsAppService {
|
|
|
1344
1619
|
let loaded = 0;
|
|
1345
1620
|
for (const record of kept) {
|
|
1346
1621
|
const raw = decodeMessage(record.raw);
|
|
1347
|
-
if (!raw?.
|
|
1622
|
+
if (!raw?.key?.remoteJid || (!raw.message && !isStubEvent(raw)))
|
|
1348
1623
|
continue;
|
|
1349
|
-
|
|
1624
|
+
const jid = this.canonical(raw.key.remoteJid);
|
|
1625
|
+
if (isNoiseJid(jid) || isControlMessage(raw))
|
|
1626
|
+
continue;
|
|
1627
|
+
this.store.putMessage(record.sid, jid, raw);
|
|
1350
1628
|
loaded++;
|
|
1351
1629
|
}
|
|
1352
1630
|
return loaded;
|
|
@@ -1356,7 +1634,7 @@ export class WhatsAppService {
|
|
|
1356
1634
|
return;
|
|
1357
1635
|
const lines = new Map();
|
|
1358
1636
|
for (const raw of messages) {
|
|
1359
|
-
if (!raw.
|
|
1637
|
+
if (!raw.key?.remoteJid)
|
|
1360
1638
|
continue;
|
|
1361
1639
|
const encoded = encode(() => proto.WebMessageInfo.encode(raw).finish());
|
|
1362
1640
|
if (!encoded)
|
|
@@ -1396,6 +1674,9 @@ const ADMIN_ACTIONS = new Set([
|
|
|
1396
1674
|
const PARTICIPANT_ACTIONS = new Set(["add", "remove", "promote", "demote"]);
|
|
1397
1675
|
/** WhatsApp answers "cannot add, invite them instead" with these codes. */
|
|
1398
1676
|
const INVITE_NEEDED_CODES = new Set(["403", "409"]);
|
|
1677
|
+
function lidKey(lid) {
|
|
1678
|
+
return `${jidNormalizedUser(lid).split("@")[0]}@lid`;
|
|
1679
|
+
}
|
|
1399
1680
|
function isAdmin(participant) {
|
|
1400
1681
|
return participant.admin === "admin" || participant.admin === "superadmin";
|
|
1401
1682
|
}
|
package/package.json
CHANGED