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 CHANGED
@@ -8,7 +8,7 @@
8
8
  ```
9
9
 
10
10
  **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
11
- chats, messages, media, contacts, groups — behind 22 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 23 tools any MCP client can
12
12
  call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
13
13
 
14
14
  Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
@@ -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
 
@@ -117,3 +117,28 @@ export function readLinkedAccount(dir) {
117
117
  export function clearAuth(dir) {
118
118
  rmSync(dir, { recursive: true, force: true });
119
119
  }
120
+ const APP_STATE_SYNC_VERSION = "app-state-sync-version";
121
+ /**
122
+ * The same auth state with the app state sync journal held at zero: reads of it
123
+ * find nothing and writes to it are dropped, every other key type untouched.
124
+ *
125
+ * WhatsApp delivers the phone's address book to a companion as `contactAction`
126
+ * mutations in the app state sync, once per stored collection version. Whichever
127
+ * socket saves those versions consumes that one delivery; every later connection
128
+ * resyncs from the version it left behind and receives nothing. The login socket
129
+ * has no store to put contacts in, so it must leave the journal for the service
130
+ * that follows.
131
+ */
132
+ export function withoutAppStateSync(state) {
133
+ return {
134
+ creds: state.creds,
135
+ keys: {
136
+ get: async (type, ids) => type === APP_STATE_SYNC_VERSION ? {} : state.keys.get(type, ids),
137
+ set: async (data) => {
138
+ const rest = { ...data };
139
+ delete rest[APP_STATE_SYNC_VERSION];
140
+ await state.keys.set(rest);
141
+ },
142
+ },
143
+ };
144
+ }
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { setTimeout as sleep } from "node:timers/promises";
5
5
  import makeWASocket, { DisconnectReason, } from "baileys";
6
6
  import qrcode from "qrcode";
7
7
  import qrcodeTerminal from "qrcode-terminal";
8
- import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
8
+ import { clearAuth, readLinkedAccount, useAtomicAuthState, withoutAppStateSync, } from "./auth-state.js";
9
9
  import { banner } from "./banner.js";
10
10
  import { runBridge } from "./bridge.js";
11
11
  import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
@@ -20,7 +20,7 @@ import { formatAge } from "./messages.js";
20
20
  import { RateLimiter } from "./ratelimit.js";
21
21
  import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
22
22
  import { applyWrites } from "./settings.js";
23
- import { bold, box, brand, humanLayout, dim, fail, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
23
+ import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
24
24
  import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
25
25
  const LOGIN_TIMEOUT_MS = 120_000;
26
26
  const LIVE_TIMEOUT_MS = 15_000;
@@ -194,6 +194,53 @@ async function runLiveProbe(config) {
194
194
  releaseLock(p.lockFile);
195
195
  }
196
196
  }
197
+ /**
198
+ * `wazap contacts resync`. One process owns the session, so this refuses while a
199
+ * server holds it rather than fighting for the socket: that server has the
200
+ * sync_contacts tool, which does the same thing.
201
+ */
202
+ export async function runContacts(config) {
203
+ if (config.args[0] !== "resync") {
204
+ say(fail(`Unknown contacts command "${config.args[0]}".`));
205
+ say(fix("Run `wazap contacts resync`"));
206
+ process.exit(2);
207
+ }
208
+ const p = paths(config.dataDir);
209
+ const running = takeSessionLock(p.lockFile);
210
+ if (running !== null) {
211
+ say(fail(`wazap is running (pid ${running}).`));
212
+ say(fix("ask your agent for the sync_contacts tool, or stop the server and run this again"));
213
+ process.exit(1);
214
+ }
215
+ const wa = new WhatsAppService(config);
216
+ const spin = spinner("Asking WhatsApp for your address book…");
217
+ try {
218
+ await wa.start();
219
+ const deadline = Date.now() + LIVE_TIMEOUT_MS;
220
+ let probe = wa.getStatus();
221
+ while (!SETTLED_STATUSES.includes(probe.status) && Date.now() < deadline) {
222
+ await sleep(250);
223
+ probe = wa.getStatus();
224
+ }
225
+ const result = await wa.syncContacts();
226
+ spin.stop(result.named_after > result.named_before
227
+ ? ok(`${result.named_after} contacts have a name (was ${result.named_before})`)
228
+ : result.named_after > 0
229
+ ? ok(`Already up to date: ${result.named_after} contacts have a name`)
230
+ : warn("WhatsApp sent no names at all. The phone has no saved contacts for these people."));
231
+ }
232
+ catch (err) {
233
+ const failure = asWazapError(err);
234
+ spin.stop(fail(failure.message));
235
+ if (failure.fix)
236
+ say(fix(failure.fix));
237
+ process.exitCode = 1;
238
+ }
239
+ finally {
240
+ await wa.stop();
241
+ releaseLock(p.lockFile);
242
+ }
243
+ }
197
244
  /** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
198
245
  export async function runGreet(config) {
199
246
  say(banner());
@@ -580,10 +627,16 @@ async function linkSession(authDir, opts) {
580
627
  if (expired)
581
628
  throw timedOut();
582
629
  const { state, saveCreds } = await useAtomicAuthState(authDir);
630
+ // This socket pairs and nothing else. It has no store, so anything it
631
+ // syncs is thrown away — and WhatsApp sends the history and the address
632
+ // book once. Refusing the history keeps it out of Baileys' sync state
633
+ // machine, which is what would otherwise bump `accountSyncCounter` and
634
+ // leave the service permanently past its own first sync.
583
635
  const sock = makeWASocket({
584
- auth: state,
636
+ auth: withoutAppStateSync(state),
585
637
  browser: WA_BROWSER,
586
638
  markOnlineOnConnect: false,
639
+ shouldSyncHistoryMessage: () => false,
587
640
  logger: SILENT_LOGGER,
588
641
  });
589
642
  current = sock;
package/dist/config.js CHANGED
@@ -29,6 +29,7 @@ const COMMAND_ARGS = {
29
29
  logout: [0],
30
30
  connect: [1],
31
31
  config: [0, 2],
32
+ contacts: [1],
32
33
  };
33
34
  const COMMANDS = Object.keys(COMMAND_ARGS);
34
35
  export function defaultDataDir() {
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, and stable under lid→pn remapping of the chat. */
17
+ /** Stable across restarts. Not stable if the chat's own jid is later remapped
18
+ * from a LID to a phone number, which is why wazap does not remap chat jids. */
18
19
  export function messageIdFor(key, chatId) {
19
20
  return `${key.fromMe ? "true" : "false"}_${chatId}_${key.id ?? ""}`;
20
21
  }
@@ -50,7 +51,28 @@ const PROTOCOL = {
50
51
  tag: (m) => (isRevoke(m) ? DELETED_TEXT : SYSTEM_TEXT),
51
52
  };
52
53
  const SYSTEM = { type: "system", tag: SYSTEM_TEXT };
53
- const UNKNOWN = { type: "unknown", tag: "[unsupported message]" };
54
+ /** Names the payload, so a bug report says which one to add. */
55
+ const UNKNOWN = {
56
+ type: "unknown",
57
+ tag: (m) => {
58
+ const key = getContentType(m) ?? Object.keys(m).find((name) => m[name] != null);
59
+ return key ? `[unsupported: ${key}]` : "[unsupported message]";
60
+ },
61
+ };
62
+ /** Payloads WhatsApp exchanges with its own clients; no person ever sent one. */
63
+ const CONTROL_KEYS = ["messageContextInfo", "senderKeyDistributionMessage"];
64
+ /**
65
+ * The protocol messages that report something a person did. Baileys enumerates
66
+ * exactly these as cross-user in Utils/process-message.js — every other type is
67
+ * one device talking to another. MESSAGE_EDIT is cross-user too, but wazap
68
+ * applies the edit to the message it edits, so its envelope is not a second
69
+ * message to show.
70
+ */
71
+ const REPORTABLE_PROTOCOL_TYPES = new Set([
72
+ proto.Message.ProtocolMessage.Type.REVOKE,
73
+ proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
74
+ proto.Message.ProtocolMessage.Type.GROUP_MEMBER_LABEL_CHANGE,
75
+ ]);
54
76
  /**
55
77
  * One table drives both messageType and messageText, so the reported type and
56
78
  * the placeholder can never disagree.
@@ -126,8 +148,13 @@ function ruleFor(content) {
126
148
  const rule = key ? RULES[key] : undefined;
127
149
  if (rule)
128
150
  return { rule, content };
129
- if (content.messageContextInfo || content.senderKeyDistributionMessage)
151
+ // Only when the control keys are all there is. A payload wazap does not model
152
+ // yet usually carries messageContextInfo alongside it, and calling that a
153
+ // system message would hide someone's event, album or order behind
154
+ // "[system message]" and then out of the digest.
155
+ if (key === undefined && (content.messageContextInfo || content.senderKeyDistributionMessage)) {
130
156
  return { rule: SYSTEM, content };
157
+ }
131
158
  return { rule: UNKNOWN, content };
132
159
  }
133
160
  function stubKind(raw) {
@@ -141,6 +168,36 @@ function stubKind(raw) {
141
168
  function resolve(value, content) {
142
169
  return typeof value === "function" ? value(content) : value;
143
170
  }
171
+ /**
172
+ * True for the machinery WhatsApp runs between devices: history-sync notices,
173
+ * app-state and peer-data responses, sender-key distribution, bare context
174
+ * info. They carry nothing a person did, so they are dropped rather than shown.
175
+ */
176
+ export function isControlMessage(raw) {
177
+ if (isStubEvent(raw))
178
+ return false;
179
+ const content = unwrapEnvelopes(raw.message);
180
+ if (!content)
181
+ return true;
182
+ const key = getContentType(content);
183
+ if (key === "protocolMessage")
184
+ return !REPORTABLE_PROTOCOL_TYPES.has(content.protocolMessage?.type ?? -1);
185
+ if (key !== undefined)
186
+ return false;
187
+ // getContentType ignores the control keys, so reaching here means the payload
188
+ // is either nothing at all or nothing but control keys.
189
+ const present = Object.keys(content).filter((name) => content[name] != null);
190
+ return present.length === 0 || present.every((name) => CONTROL_KEYS.includes(name));
191
+ }
192
+ /**
193
+ * A message whose whole content is a stub type: WhatsApp's own notices about
194
+ * device linking, group membership and encryption. Baileys builds these with no
195
+ * `message` field at all, so they have to be recognised before the usual
196
+ * "no content, nothing to store" guard throws them away.
197
+ */
198
+ export function isStubEvent(raw) {
199
+ return stubKind(raw) !== undefined;
200
+ }
144
201
  export function messageType(raw) {
145
202
  const stub = stubKind(raw);
146
203
  if (stub === "deleted")
@@ -244,7 +301,7 @@ export function buildMessageView(raw, ctx) {
244
301
  from_me: Boolean(raw.key.fromMe),
245
302
  sender: {
246
303
  id: sender,
247
- name: ctx.nameFor(sender) || raw.pushName || phoneOf(sender) || sender,
304
+ name: ctx.nameFor(sender),
248
305
  ...(phoneOf(sender) ? { phone: phoneOf(sender) } : {}),
249
306
  },
250
307
  type: messageType(raw),
package/dist/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
- and the versions and data directory in use.
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 makeWASocket, { Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
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 = { v: 1, chats: {}, contacts: {}, messages: {}, byChat: {} };
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.store.contacts.size, messages: this.store.messages.size };
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 chats = this.knownChats()
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
- .map((chat) => this.chatSummary(chat));
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
- const last = messages[messages.length - 1];
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.nameFor(jid),
410
+ chat_name: this.displayName(jid),
315
411
  type: isGroupId(jid) ? "group" : "individual",
316
- last_activity: last ? last.timestamp : isoWithOffset(cutoff),
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
- const name = (contact.name ?? "").toLowerCase();
359
- const notify = (contact.notify ?? "").toLowerCase();
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.nameFor(id), is_admin: isAdmin(p) };
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.groupCache.set(this.canonical(meta.id), meta);
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.groupCache.set(this.canonical(meta.id), meta);
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.groupCache.set(jid, meta);
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
- const key = `${jidNormalizedUser(lid).split("@")[0]}@lid`;
1079
- this.lidToPn.set(key, pn);
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
- nameFor(jid) {
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
- const chat = this.store.chats.get(jid);
1085
- if (chat?.name)
1086
- return chat.name;
1087
- const contact = this.store.contacts.get(jid);
1088
- if (contact?.name || contact?.notify)
1089
- return contact.name || contact.notify || "";
1090
- if (isGroupId(jid))
1091
- return this.groupCache.get(jid)?.subject ?? jid;
1092
- return jid.split("@")[0] ?? jid;
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.nameFor(jid),
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: chat.name || this.nameFor(jid),
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 number = jid.endsWith("@s.whatsapp.net") ? (jid.split("@")[0] ?? null) : null;
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: contact?.name || contact?.notify || number || jid,
1459
+ name: this.displayName(jid),
1213
1460
  number,
1214
- is_my_contact: Boolean(contact?.name),
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
- if (contact.lid && contact.phoneNumber)
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.message || !raw.key?.remoteJid)
1496
+ if (!raw.key?.remoteJid || (!raw.message && !isStubEvent(raw)))
1249
1497
  continue;
1250
1498
  const jid = this.canonical(raw.key.remoteJid);
1251
- if (jid === "status@broadcast")
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?.message || !raw.key?.remoteJid)
1622
+ if (!raw?.key?.remoteJid || (!raw.message && !isStubEvent(raw)))
1348
1623
  continue;
1349
- this.store.putMessage(record.sid, this.canonical(raw.key.remoteJid), raw);
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.message || !raw.key?.remoteJid)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
5
5
  "license": "MIT",
6
6
  "author": "Răzvan Girgiz",