wazap-mcp 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,8 +180,8 @@ manifest, the icon and a fresh production `node_modules`, then packs them with
180
180
  | `learn` | read | The guide to every tool, id format and error code. Call it first. |
181
181
  | `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
182
182
  | `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
183
- | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone. |
184
- | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices. |
183
+ | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`. |
184
+ | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices, `types` narrows to one or more message types. |
185
185
  | `search_messages` | read | Text search across the locally held messages. |
186
186
  | `get_message` | read | One message in full, with its quoted message and reactions. |
187
187
  | `search_contacts` | read | Find contacts by name or number. |
@@ -404,6 +404,12 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
404
404
  state sync, and only to a connection asking for it from scratch. If contacts
405
405
  read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
406
406
  again with the `sync_contacts` tool or `wazap contacts resync`.
407
+ - **Calls are WhatsApp calls only.** A call shows up as a message with
408
+ `type: "call"`, carrying its kind, direction, outcome and duration. WhatsApp's
409
+ own call log and the missed-call notices arrive on their own; a call that
410
+ starts and ends while wazap is running is recorded live, so calls placed or
411
+ received while it is stopped can be missing entirely. A cellular call from the
412
+ phone's dialler is never visible, on any device.
407
413
  - **Your phone must stay reachable.** A linked device stops receiving once the
408
414
  phone has been offline long enough; `get_status` says so in `hint`.
409
415
 
package/dist/calls.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Live calls. Baileys reports a call as a stream of status events and never as
3
+ * a message, so this folds that stream into one entry per call and hands back a
4
+ * synthetic WAMessage the ordinary store path can carry. Pure: no timers, no
5
+ * socket, no store, so a test can drive it by feeding events and a clock.
6
+ */
7
+ import { proto } from "baileys";
8
+ /** A ringing call nobody answered and nobody hung up: the terminal event was lost. */
9
+ const RING_TIMEOUT_MS = 2 * 60_000;
10
+ /**
11
+ * An answered call is not expired at the ring timeout, which would invent a
12
+ * two-minute duration for a conversation still going on. It is only cut loose
13
+ * once it has run longer than any real call does.
14
+ */
15
+ const ANSWERED_CAP_MS = 6 * 3_600_000;
16
+ /**
17
+ * How many settled call ids to remember. They are what makes a repeated
18
+ * terminal event store nothing twice, and the process is long-lived, so the set
19
+ * has to forget its oldest eventually rather than grow for the whole session.
20
+ */
21
+ const SETTLED_MEMORY = 500;
22
+ /** Marks a stored message as one wazap tracked itself. See `isTrackedCall`. */
23
+ const TRACKED_ID_PREFIX = "call_";
24
+ const OUTCOME_CODES = {
25
+ answered: proto.Message.CallLogMessage.CallOutcome.CONNECTED,
26
+ rejected: proto.Message.CallLogMessage.CallOutcome.REJECTED,
27
+ missed: proto.Message.CallLogMessage.CallOutcome.MISSED,
28
+ unanswered: proto.Message.CallLogMessage.CallOutcome.MISSED,
29
+ };
30
+ function noAnswer(direction) {
31
+ return direction === "outgoing" ? "unanswered" : "missed";
32
+ }
33
+ /**
34
+ * `from` arrives as a LID as often as a phone jid, and either can carry a
35
+ * device suffix, so only the user part of the two jids is comparable.
36
+ */
37
+ function samePerson(one, other) {
38
+ const user = (jid) => (jid.split("@")[0] ?? "").split(":")[0] ?? "";
39
+ const left = user(one);
40
+ return left.length > 0 && left === user(other);
41
+ }
42
+ export class CallTracker {
43
+ calls = new Map();
44
+ settled = new Set();
45
+ get pending() {
46
+ return this.calls.size;
47
+ }
48
+ /** The entry to store once the call reaches a terminal state, else null. */
49
+ observe(event, ownJid, now) {
50
+ if (!event.id || this.settled.has(event.id))
51
+ return null;
52
+ const call = this.calls.get(event.id) ?? this.begin(event, ownJid, now);
53
+ call.lastSeen = now;
54
+ switch (event.status) {
55
+ case "accept":
56
+ call.acceptedAt = now;
57
+ return null;
58
+ case "reject":
59
+ return this.finish(call, "rejected");
60
+ case "timeout":
61
+ return this.finish(call, noAnswer(call.direction));
62
+ case "terminate":
63
+ return call.acceptedAt === undefined
64
+ ? this.finish(call, noAnswer(call.direction))
65
+ : this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000));
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+ /** Entries for calls whose terminal event never arrived. */
71
+ expire(now) {
72
+ const done = [];
73
+ for (const call of [...this.calls.values()]) {
74
+ if (call.acceptedAt === undefined) {
75
+ if (now - call.lastSeen >= RING_TIMEOUT_MS)
76
+ done.push(this.finish(call, noAnswer(call.direction)));
77
+ }
78
+ else if (now - call.acceptedAt >= ANSWERED_CAP_MS) {
79
+ done.push(this.finish(call, "answered", Math.round((now - call.acceptedAt) / 1000)));
80
+ }
81
+ }
82
+ return done;
83
+ }
84
+ /**
85
+ * An event for an unknown call-id starts a pending call from whatever it
86
+ * carries, so a restart in the middle of one still records something. Only
87
+ * the offer names isVideo and the group, which is why baileys replays them
88
+ * from its own cache onto the later events of the same call.
89
+ */
90
+ begin(event, ownJid, now) {
91
+ const offered = event.date instanceof Date ? event.date.getTime() : Number.NaN;
92
+ const chat = (event.isGroup ? (event.groupJid ?? event.chatId) : event.chatId) || event.from;
93
+ const call = {
94
+ callId: event.id,
95
+ chatId: chat,
96
+ at: Number.isFinite(offered) ? offered : now,
97
+ kind: event.isVideo ? "video" : "voice",
98
+ direction: samePerson(event.from, ownJid) ? "outgoing" : "incoming",
99
+ lastSeen: now,
100
+ };
101
+ this.calls.set(event.id, call);
102
+ return call;
103
+ }
104
+ finish(call, outcome, durationSeconds) {
105
+ this.calls.delete(call.callId);
106
+ this.settled.add(call.callId);
107
+ if (this.settled.size > SETTLED_MEMORY) {
108
+ const oldest = this.settled.values().next().value;
109
+ if (oldest !== undefined)
110
+ this.settled.delete(oldest);
111
+ }
112
+ return {
113
+ callId: call.callId,
114
+ chatId: call.chatId,
115
+ at: call.at,
116
+ kind: call.kind,
117
+ direction: call.direction,
118
+ outcome,
119
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
120
+ };
121
+ }
122
+ }
123
+ /**
124
+ * True for a message this tracker built. The tracker emits one entry per call
125
+ * id, so two of these are always two different calls however close together
126
+ * they fall, which is the one thing a dedupe by timestamp cannot know.
127
+ */
128
+ export function isTrackedCall(raw) {
129
+ return (raw.key?.id ?? "").startsWith(TRACKED_ID_PREFIX);
130
+ }
131
+ /**
132
+ * The entry as WhatsApp would have logged it, so snapshot, history JSONL, views
133
+ * and list_chats all carry a live call with no machinery of their own. The
134
+ * fields have to survive an encode/decode round trip, because that is what
135
+ * persistence does to it.
136
+ */
137
+ export function callMessage(entry) {
138
+ return {
139
+ key: {
140
+ remoteJid: entry.chatId,
141
+ fromMe: entry.direction === "outgoing",
142
+ id: `${TRACKED_ID_PREFIX}${entry.callId}`,
143
+ },
144
+ messageTimestamp: Math.floor(entry.at / 1000),
145
+ message: {
146
+ callLogMesssage: {
147
+ isVideo: entry.kind === "video",
148
+ callOutcome: OUTCOME_CODES[entry.outcome],
149
+ ...(entry.durationSeconds === undefined ? {} : { durationSecs: entry.durationSeconds }),
150
+ },
151
+ },
152
+ };
153
+ }
package/dist/messages.js CHANGED
@@ -165,6 +165,83 @@ function stubKind(raw) {
165
165
  return "deleted";
166
166
  return stub === proto.WebMessageInfo.StubType.UNKNOWN ? undefined : "system";
167
167
  }
168
+ const CALL_OUTCOMES = {
169
+ [proto.Message.CallLogMessage.CallOutcome.CONNECTED]: "answered",
170
+ [proto.Message.CallLogMessage.CallOutcome.ACCEPTED_ELSEWHERE]: "answered",
171
+ [proto.Message.CallLogMessage.CallOutcome.ONGOING]: "answered",
172
+ [proto.Message.CallLogMessage.CallOutcome.REJECTED]: "rejected",
173
+ [proto.Message.CallLogMessage.CallOutcome.MISSED]: "no answer",
174
+ [proto.Message.CallLogMessage.CallOutcome.FAILED]: "no answer",
175
+ [proto.Message.CallLogMessage.CallOutcome.SILENCED_BY_DND]: "no answer",
176
+ [proto.Message.CallLogMessage.CallOutcome.SILENCED_UNKNOWN_CALLER]: "no answer",
177
+ };
178
+ const CALL_STUB_KINDS = {
179
+ [proto.WebMessageInfo.StubType.CALL_MISSED_VOICE]: "voice",
180
+ [proto.WebMessageInfo.StubType.CALL_MISSED_VIDEO]: "video",
181
+ [proto.WebMessageInfo.StubType.CALL_MISSED_GROUP_VOICE]: "voice",
182
+ [proto.WebMessageInfo.StubType.CALL_MISSED_GROUP_VIDEO]: "video",
183
+ };
184
+ function settle(outcome, direction) {
185
+ if (outcome !== "no answer")
186
+ return outcome;
187
+ return direction === "outgoing" ? "unanswered" : "missed";
188
+ }
189
+ /**
190
+ * Calls never reach the RULES table: `getContentType` looks for a key
191
+ * containing "Message" and the proto field is spelled `callLogMesssage`, so it
192
+ * reports undefined and a call arriving next to messageContextInfo would render
193
+ * as "[system message]". Hence this runs before the table, not inside it.
194
+ */
195
+ export function callInfo(raw) {
196
+ const direction = raw.key?.fromMe ? "outgoing" : "incoming";
197
+ const content = unwrapEnvelopes(raw.message);
198
+ const logged = content?.callLogMesssage;
199
+ if (logged) {
200
+ const outcome = settle(CALL_OUTCOMES[logged.callOutcome ?? -1] ?? "no answer", direction);
201
+ const seconds = protoNumber(logged.durationSecs);
202
+ const participants = (logged.participants ?? []).flatMap((one) => (one.jid ? [one.jid] : []));
203
+ return {
204
+ kind: logged.isVideo ? "video" : "voice",
205
+ direction,
206
+ outcome,
207
+ ...(outcome === "answered" && seconds !== undefined && seconds > 0 ? { duration_seconds: seconds } : {}),
208
+ ...(participants.length > 0 ? { participants } : {}),
209
+ };
210
+ }
211
+ const stub = CALL_STUB_KINDS[raw.messageStubType ?? -1];
212
+ if (stub)
213
+ return { kind: stub, direction, outcome: settle("no answer", direction) };
214
+ if (content?.call != null)
215
+ return { kind: "voice", direction, outcome: settle("no answer", direction) };
216
+ return undefined;
217
+ }
218
+ /**
219
+ * Baileys' own stand-in for a group call offer. It says a call happened and
220
+ * nothing else, so anything that names an outcome outranks it.
221
+ */
222
+ export function isCallPlaceholder(raw) {
223
+ const content = unwrapEnvelopes(raw.message);
224
+ return content?.call != null && content.callLogMesssage == null;
225
+ }
226
+ function durationLabel(seconds) {
227
+ if (seconds < 60)
228
+ return `${seconds}s`;
229
+ const minutes = Math.round(seconds / 60);
230
+ if (minutes < 60)
231
+ return `${minutes} min`;
232
+ const hours = Math.floor(minutes / 60);
233
+ const rest = minutes % 60;
234
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest} min`;
235
+ }
236
+ /** An outcome you caused reads as a suffix; one that happened to you is an adjective. */
237
+ export function callText(info) {
238
+ const duration = info.duration_seconds === undefined ? "" : ` · ${durationLabel(info.duration_seconds)}`;
239
+ if (info.direction === "outgoing") {
240
+ return `[outgoing ${info.kind} call${info.outcome === "answered" ? duration : ` · ${info.outcome}`}]`;
241
+ }
242
+ const adjective = info.outcome === "answered" ? "" : `${info.outcome} `;
243
+ return `[${adjective}${info.kind} call${duration}]`;
244
+ }
168
245
  function resolve(value, content) {
169
246
  return typeof value === "function" ? value(content) : value;
170
247
  }
@@ -199,6 +276,8 @@ export function isStubEvent(raw) {
199
276
  return stubKind(raw) !== undefined;
200
277
  }
201
278
  export function messageType(raw) {
279
+ if (callInfo(raw))
280
+ return "call";
202
281
  const stub = stubKind(raw);
203
282
  if (stub === "deleted")
204
283
  return "deleted";
@@ -210,6 +289,11 @@ export function messageType(raw) {
210
289
  }
211
290
  /** Never empty: media and system messages get a placeholder like "[sticker]". */
212
291
  export function messageText(raw) {
292
+ const call = callInfo(raw);
293
+ // The placeholder only says a group call was offered, so naming an outcome
294
+ // ("missed") would claim something the payload never carried.
295
+ if (call)
296
+ return isCallPlaceholder(raw) ? "[group call]" : callText(call);
213
297
  const content = unwrapEnvelopes(raw.message);
214
298
  const { rule, content: node } = ruleFor(content);
215
299
  if (rule === UNKNOWN) {
@@ -295,6 +379,7 @@ export function buildMessageView(raw, ctx) {
295
379
  const media = mediaInfo(raw);
296
380
  const context = contextInfo(raw);
297
381
  const quoted = context?.quotedMessage ? quotedView(context, ctx) : undefined;
382
+ const call = callInfo(raw);
298
383
  const view = {
299
384
  message_id: messageIdFor(raw.key, ctx.chatId),
300
385
  chat_id: ctx.chatId,
@@ -316,6 +401,11 @@ export function buildMessageView(raw, ctx) {
316
401
  view.media = media;
317
402
  if (quoted)
318
403
  view.quoted = quoted;
404
+ if (call) {
405
+ view.call = call.participants
406
+ ? { ...call, participants: call.participants.map((jid) => ctx.canonical(jid)) }
407
+ : call;
408
+ }
319
409
  if (ctx.reactions.length > 0)
320
410
  view.reactions = ctx.reactions;
321
411
  return view;
package/dist/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { asWazapError, ERROR_GUIDE } from "./errors.js";
3
+ import { MESSAGE_TYPES } from "./wa-types.js";
3
4
  function tool(def) {
4
5
  return { ...def, handler: def.handler };
5
6
  }
@@ -25,6 +26,10 @@ const messageId = z
25
26
  .string()
26
27
  .min(5)
27
28
  .describe('Message id from read_messages / search_messages / get_message, e.g. "false_4072...@s.whatsapp.net_3EB0..."');
29
+ const messageTypes = z
30
+ .array(z.enum([...MESSAGE_TYPES]))
31
+ .optional()
32
+ .describe('Keep only these message types; omit for every type. The limit counts matching messages, so ["call"] returns that many calls, not that many messages of which some are calls.');
28
33
  const GUIDE = `# wazap — WhatsApp for your AI agent
29
34
 
30
35
  Read/write access to the user's linked WhatsApp account: chats, messages, media,
@@ -61,6 +66,11 @@ which look like a phone number and are not one.
61
66
  WhatsApp's own notices (device linking, group membership, encryption) have
62
67
  \`type: "system"\` and are left out of get_recent_messages unless you pass
63
68
  include_system: true.
69
+ A WhatsApp call is a message with \`type: "call"\` carrying
70
+ \`call: {kind, direction, outcome, duration_seconds}\`, reading as
71
+ "[voice call · 6 min]" or "[missed voice call]".
72
+ read_messages and get_recent_messages take \`types\` to narrow to a subset of
73
+ these types, e.g. \`types: ["call"]\` for the call log of a chat.
64
74
  \`timestamp\` is ISO 8601 with the machine's UTC offset, \`age\` is human-readable.
65
75
 
66
76
  ## Errors
@@ -142,11 +152,12 @@ older history when the local store runs out, which takes a few seconds.`,
142
152
  chat_id: chatId,
143
153
  limit: z.number().int().min(1).max(200).default(20).describe("Maximum number of messages (1-200)"),
144
154
  before: messageId.optional().describe("Return the messages immediately older than this message_id"),
155
+ types: messageTypes,
145
156
  },
146
157
  write: false,
147
- handler: async ({ chat_id, limit, before }, wa) => {
148
- const result = await wa.readMessages(chat_id, limit, before);
149
- return ok(renderMessages(`Messages in ${chat_id}`, result.data), synced(result, { chat_id, count: result.data.length, messages: result.data }));
158
+ handler: async ({ chat_id, limit, before, types }, wa) => {
159
+ const result = await wa.readMessages(chat_id, limit, before, types);
160
+ return ok(renderMessages(`Messages in ${chat_id}`, result.data), synced(result, { chat_id, types, count: result.data.length, messages: result.data }));
150
161
  },
151
162
  }),
152
163
  tool({
@@ -166,15 +177,17 @@ out so the counts are conversation; pass include_system to see them.`,
166
177
  .boolean()
167
178
  .default(false)
168
179
  .describe("Include WhatsApp's own system notices, which are excluded from the bodies and the counts by default"),
180
+ types: messageTypes,
169
181
  },
170
182
  write: false,
171
- handler: async ({ hours, filter, include_system }, wa) => {
172
- const result = await wa.getRecentMessages(hours, filter, include_system);
183
+ handler: async ({ hours, filter, include_system, types }, wa) => {
184
+ const result = await wa.getRecentMessages(hours, filter, include_system, types);
173
185
  const messageCount = result.data.reduce((n, c) => n + c.messages.length, 0);
174
186
  return ok(renderConversations(result.data, hours), synced(result, {
175
187
  hours,
176
188
  filter,
177
189
  include_system,
190
+ types,
178
191
  conversation_count: result.data.length,
179
192
  message_count: messageCount,
180
193
  conversations: result.data,
package/dist/wa-types.js CHANGED
@@ -1,2 +1,20 @@
1
1
  /** Public shapes of the WhatsApp service: what the MCP tools and the CLI consume. */
2
- export {};
2
+ /** The zod enum the tools expose derives from this, so the two cannot drift. */
3
+ export const MESSAGE_TYPES = [
4
+ "text",
5
+ "image",
6
+ "video",
7
+ "audio",
8
+ "voice",
9
+ "document",
10
+ "sticker",
11
+ "location",
12
+ "contact",
13
+ "poll",
14
+ "reaction",
15
+ "deleted",
16
+ "view_once",
17
+ "call",
18
+ "system",
19
+ "unknown",
20
+ ];
package/dist/whatsapp.js CHANGED
@@ -10,11 +10,12 @@ import { isAbsolute, join } from "node:path";
10
10
  import { setTimeout as sleep } from "node:timers/promises";
11
11
  import makeWASocket, { ALL_WA_PATCH_NAMES, Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
12
12
  import { readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
13
+ import { CallTracker, callMessage, isTrackedCall } from "./calls.js";
13
14
  import { BAILEYS_VERSION, paths, WAZAP_VERSION } from "./config.js";
14
15
  import { asWazapError, RELINK_FIX, RESET_FIX, WazapError } from "./errors.js";
15
16
  import { isGroupId, isNoiseJid, resolveChatId } from "./ids.js";
16
17
  import { log, logError } from "./logger.js";
17
- import { buildMessageView, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
18
+ import { buildMessageView, callInfo, isCallPlaceholder, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, messageType, protoNumber, } from "./messages.js";
18
19
  /** Reconnect pacing. A closed socket used to be retried instantly, which turns
19
20
  * any persistent rejection into a login storm — WhatsApp answers that by
20
21
  * throttling the account and refusing to link *any* new device to it, phone
@@ -35,6 +36,10 @@ const STALE_INBOUND_MS = 24 * 3_600_000;
35
36
  const MAX_MESSAGES_PER_CHAT = 1_000;
36
37
  const PERSIST_MESSAGES_PER_CHAT = 120;
37
38
  const STORE_SAVE_DEBOUNCE_MS = 20_000;
39
+ const CALL_SWEEP_MS = 30_000;
40
+ /** The same call reaches the store up to three ways; only nearness in time tells them apart. */
41
+ const CALL_DEDUPE_WINDOW_MS = 60_000;
42
+ const CALL_DEDUPE_SCAN = 20;
38
43
  const HISTORY_STORE_CAP_PER_CHAT = 2_000;
39
44
  const DIR_MODE = 0o700;
40
45
  const FILE_MODE = 0o600;
@@ -121,13 +126,35 @@ class Store {
121
126
  ring.sort((a, b) => this.seconds(a) - this.seconds(b));
122
127
  while (ring.length > MAX_MESSAGES_PER_CHAT) {
123
128
  const dropped = ring.shift();
124
- if (dropped) {
125
- this.messages.delete(dropped);
126
- this.chatOf.delete(dropped);
127
- this.edited.delete(dropped);
128
- this.reactions.delete(dropped);
129
- }
129
+ if (dropped)
130
+ this.forget(dropped);
131
+ }
132
+ }
133
+ /** The tail of a chat, newest first. */
134
+ recent(chatJid, count) {
135
+ const ring = this.byChat.get(chatJid) ?? [];
136
+ const tail = [];
137
+ for (let i = ring.length - 1; i >= 0 && tail.length < count; i--) {
138
+ const sid = ring[i];
139
+ const raw = this.messages.get(sid);
140
+ if (raw)
141
+ tail.push({ sid, raw });
130
142
  }
143
+ return tail;
144
+ }
145
+ /** Forget one message entirely, its place in the chat included. */
146
+ dropMessage(sid) {
147
+ const ring = this.byChat.get(this.chatOf.get(sid) ?? "");
148
+ const at = ring?.indexOf(sid) ?? -1;
149
+ if (ring && at !== -1)
150
+ ring.splice(at, 1);
151
+ this.forget(sid);
152
+ }
153
+ forget(sid) {
154
+ this.messages.delete(sid);
155
+ this.chatOf.delete(sid);
156
+ this.edited.delete(sid);
157
+ this.reactions.delete(sid);
131
158
  }
132
159
  reactionsFor(sid) {
133
160
  const map = this.reactions.get(sid);
@@ -227,6 +254,7 @@ export class WhatsAppService {
227
254
  historyWaiters = [];
228
255
  storeDirty = false;
229
256
  storeSaveTimer = null;
257
+ callSweepTimer = null;
230
258
  persistedLoaded = false;
231
259
  contactResyncTried = false;
232
260
  blocked = new Set();
@@ -238,6 +266,7 @@ export class WhatsAppService {
238
266
  /** The same, for naming only, and it holds more. See `learnLidPhone`. */
239
267
  lidPhones = new Map();
240
268
  store = new Store();
269
+ calls = new CallTracker();
241
270
  paths;
242
271
  constructor(config) {
243
272
  this.config = config;
@@ -289,6 +318,7 @@ export class WhatsAppService {
289
318
  this.storeSaveTimer = null;
290
319
  this.reconnectTimer = null;
291
320
  this.syncDeadline = null;
321
+ this.stopCallSweep();
292
322
  this.releaseWaiters();
293
323
  await this.flushStore();
294
324
  this.teardownSocket();
@@ -361,7 +391,7 @@ export class WhatsAppService {
361
391
  return this.synced(chats);
362
392
  });
363
393
  }
364
- readMessages(chatId, limit, before) {
394
+ readMessages(chatId, limit, before, types) {
365
395
  return this.guarded(async () => {
366
396
  const sock = this.ensureConnected();
367
397
  const jid = this.resolveId(chatId);
@@ -369,19 +399,19 @@ export class WhatsAppService {
369
399
  await this.learnParticipants(jid);
370
400
  await this.learnLidPhones([jid]);
371
401
  if (before === undefined) {
372
- const ring = this.store.byChat.get(jid) ?? [];
402
+ const ring = this.ofTypes(this.store.byChat.get(jid) ?? [], types);
373
403
  return this.synced(this.viewsFor(ring.slice(-limit), jid));
374
404
  }
375
405
  const anchor = this.messageOrThrow(before);
376
- let older = this.olderThan(jid, before, limit);
406
+ let older = this.olderThan(jid, before, limit, types);
377
407
  if (older.length === 0) {
378
408
  await this.fetchOlder(sock, anchor, limit);
379
- older = this.olderThan(jid, before, limit);
409
+ older = this.olderThan(jid, before, limit, types);
380
410
  }
381
411
  return this.synced(this.viewsFor(older, jid));
382
412
  });
383
413
  }
384
- getRecentMessages(hours, filter, includeSystem = false) {
414
+ getRecentMessages(hours, filter, includeSystem = false, types) {
385
415
  return this.guarded(async () => {
386
416
  this.ensureConnected();
387
417
  await this.waitForSync();
@@ -402,7 +432,7 @@ export class WhatsAppService {
402
432
  });
403
433
  if (recent.length === 0)
404
434
  continue;
405
- const messages = this.viewsFor(recent, jid).filter((view) => includeSystem || view.type !== "system");
435
+ const messages = this.viewsFor(this.ofTypes(recent, types), jid).filter((view) => includeSystem || view.type !== "system");
406
436
  if (messages.length === 0)
407
437
  continue;
408
438
  conversations.push({
@@ -838,6 +868,18 @@ export class WhatsAppService {
838
868
  this.markSyncDone();
839
869
  this.markStoreDirty();
840
870
  });
871
+ sock.ev.on("call", ([call]) => {
872
+ if (generation !== this.generation || !call)
873
+ return;
874
+ // WhatsApp addresses a call node by LID as often as by number, and ownJid
875
+ // is only ever the number, so an outgoing call reads as incoming unless
876
+ // the two are brought into the same form first.
877
+ const from = this.canonical(call.from);
878
+ const entry = this.calls.observe({ ...call, from }, this.ownJid(), Date.now());
879
+ if (entry)
880
+ this.storeCall(entry);
881
+ this.armCallSweep();
882
+ });
841
883
  sock.ev.on("lid-mapping.update", (mapping) => this.learnLid(mapping.lid, mapping.pn));
842
884
  sock.ev.on("chats.upsert", (chats) => {
843
885
  for (const chat of chats)
@@ -1364,12 +1406,26 @@ export class WhatsAppService {
1364
1406
  viewsFor(sids, chatJid) {
1365
1407
  return sids.filter((sid) => this.store.messages.has(sid)).map((sid) => this.viewOf(sid, chatJid));
1366
1408
  }
1367
- olderThan(chatJid, before, limit) {
1409
+ /** Absent and empty both mean every type: narrowing is opt-in, never a default. */
1410
+ ofTypes(sids, types) {
1411
+ if (types === undefined || types.length === 0)
1412
+ return sids;
1413
+ return sids.filter((sid) => {
1414
+ const raw = this.store.messages.get(sid);
1415
+ return raw !== undefined && types.includes(messageType(raw));
1416
+ });
1417
+ }
1418
+ /**
1419
+ * The anchor is found in the unfiltered ring, so paging never depends on the
1420
+ * filter, and `limit` then counts messages the caller asked for rather than
1421
+ * messages we are about to throw away.
1422
+ */
1423
+ olderThan(chatJid, before, limit, types) {
1368
1424
  const ring = this.store.byChat.get(chatJid) ?? [];
1369
1425
  const at = ring.indexOf(before);
1370
1426
  if (at <= 0)
1371
1427
  return [];
1372
- return ring.slice(Math.max(0, at - limit), at);
1428
+ return this.ofTypes(ring.slice(0, at), types).slice(-limit);
1373
1429
  }
1374
1430
  async fetchOlder(sock, anchor, limit) {
1375
1431
  const seconds = Math.floor(messageTimestampMs(anchor) / 1000);
@@ -1499,11 +1555,74 @@ export class WhatsAppService {
1499
1555
  if (isNoiseJid(jid) || isControlMessage(raw))
1500
1556
  continue;
1501
1557
  this.learnPushName(raw, jid);
1502
- this.store.putMessage(messageIdFor(raw.key, jid), jid, raw);
1558
+ const sid = messageIdFor(raw.key, jid);
1559
+ if (!this.keepOverEarlierCall(raw, jid, sid))
1560
+ continue;
1561
+ this.store.putMessage(sid, jid, raw);
1503
1562
  stored.push(raw);
1504
1563
  }
1505
1564
  return stored;
1506
1565
  }
1566
+ /**
1567
+ * One call can reach the store three ways: wazap's own tracker, the stub
1568
+ * baileys synthesises on a timeout, and WhatsApp's later call-log message.
1569
+ * Each carries a different id, so only nearness in time pairs them up, and
1570
+ * whichever says more about the call is the one worth keeping. The history
1571
+ * reload runs it too: the JSONL still holds the line the loser wrote before
1572
+ * it was dropped, and a restart would otherwise bring the pair back.
1573
+ */
1574
+ keepOverEarlierCall(raw, chatJid, sid) {
1575
+ const info = callInfo(raw);
1576
+ if (!info)
1577
+ return true;
1578
+ const at = messageTimestampMs(raw);
1579
+ for (const known of this.store.recent(chatJid, CALL_DEDUPE_SCAN)) {
1580
+ if (known.sid === sid)
1581
+ continue;
1582
+ const other = callInfo(known.raw);
1583
+ if (!other)
1584
+ continue;
1585
+ // A redial inside the window is two calls, and wazap knows it built both.
1586
+ if (isTrackedCall(raw) && isTrackedCall(known.raw))
1587
+ continue;
1588
+ if (Math.abs(messageTimestampMs(known.raw) - at) > CALL_DEDUPE_WINDOW_MS)
1589
+ continue;
1590
+ if (callDetail(raw, info) <= callDetail(known.raw, other))
1591
+ return false;
1592
+ this.store.dropMessage(known.sid);
1593
+ return true;
1594
+ }
1595
+ return true;
1596
+ }
1597
+ /** A live call goes in the way any message does, so everything downstream carries it. */
1598
+ storeCall(entry) {
1599
+ const stored = this.ingestMessages([callMessage(entry)]);
1600
+ if (stored.length === 0)
1601
+ return;
1602
+ void this.appendHistory(stored);
1603
+ this.markStoreDirty();
1604
+ }
1605
+ /**
1606
+ * Only while a call is in flight: a call whose terminal event never arrives
1607
+ * would otherwise sit pending forever, and a timer with nothing to do would
1608
+ * otherwise keep ticking for the life of the process.
1609
+ */
1610
+ armCallSweep() {
1611
+ if (this.callSweepTimer || this.calls.pending === 0)
1612
+ return;
1613
+ this.callSweepTimer = setInterval(() => {
1614
+ for (const entry of this.calls.expire(Date.now()))
1615
+ this.storeCall(entry);
1616
+ if (this.calls.pending === 0)
1617
+ this.stopCallSweep();
1618
+ }, CALL_SWEEP_MS);
1619
+ this.callSweepTimer.unref();
1620
+ }
1621
+ stopCallSweep() {
1622
+ if (this.callSweepTimer)
1623
+ clearInterval(this.callSweepTimer);
1624
+ this.callSweepTimer = null;
1625
+ }
1507
1626
  learnPushName(raw, chatJid) {
1508
1627
  const name = raw.pushName?.trim();
1509
1628
  if (!name || raw.key.fromMe)
@@ -1624,6 +1743,8 @@ export class WhatsAppService {
1624
1743
  const jid = this.canonical(raw.key.remoteJid);
1625
1744
  if (isNoiseJid(jid) || isControlMessage(raw))
1626
1745
  continue;
1746
+ if (!this.keepOverEarlierCall(raw, jid, record.sid))
1747
+ continue;
1627
1748
  this.store.putMessage(record.sid, jid, raw);
1628
1749
  loaded++;
1629
1750
  }
@@ -1674,6 +1795,12 @@ const ADMIN_ACTIONS = new Set([
1674
1795
  const PARTICIPANT_ACTIONS = new Set(["add", "remove", "promote", "demote"]);
1675
1796
  /** WhatsApp answers "cannot add, invite them instead" with these codes. */
1676
1797
  const INVITE_NEEDED_CODES = new Set(["403", "409"]);
1798
+ /** How much a call message says. A duration is the most it can carry. */
1799
+ function callDetail(raw, info) {
1800
+ if (info.duration_seconds !== undefined)
1801
+ return 2;
1802
+ return isCallPlaceholder(raw) ? 0 : 1;
1803
+ }
1677
1804
  function lidKey(lid) {
1678
1805
  return `${jidNormalizedUser(lid).split("@")[0]}@lid`;
1679
1806
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "mcpName": "io.github.razvangirgiz/wazap",
5
5
  "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 22 tools, stdio or token-protected HTTP.",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@ Work through the messages once and collect:
19
19
 
20
20
  - **Decisions**: something agreed or announced by an admin or by the people it concerns ("ok, Saturday at 10 then").
21
21
  - **Dates and deadlines**: any concrete day, time, or "by Friday", with what it is for.
22
- - **Asks of the user**: every mention or reply to them, plus open questions nobody answered that fall on the user.
22
+ - **Asks of the user**: every mention or reply to them, plus open questions nobody answered that fall on the user. A `call` message in the group after one of these, with `call.outcome` `answered`, means the user was on that group call: say so and treat the ask as probably handled, the way `whatsapp-inbox` does.
23
23
  - **Open threads**: questions still without an answer, for anyone.
24
24
  - **Polls**: the question and options (`[poll] …`); wazap cannot read votes, so say that if the user asks who voted.
25
25
 
@@ -20,11 +20,20 @@ Done collecting when every chat with unread messages appears in exactly one buck
20
20
  Sort each chat into one bucket:
21
21
 
22
22
  - **Needs you**: a direct question to the user, a request, a mention of the user in a group (`sender` is not the user and the text addresses them or quotes one of their messages), or money/dates/decisions awaiting them.
23
+ - **Probably handled by call**: a *Needs you* candidate the user has since called. See *Calls* below.
23
24
  - **FYI**: information with no ask. Shipping updates, "ok thanks", group chatter that reached a conclusion.
24
25
  - **Noise**: promotions, broadcast lists, groups the user is muted in (`muted_until` in the future), forwards without a question.
25
26
 
26
27
  Rank *Needs you* by: people over groups, older unanswered over newer, money and deadlines first.
27
28
 
29
+ ### Calls
30
+
31
+ A call after someone's ask is evidence the user dealt with it. For every *Needs you* candidate from an individual chat, look for a `call` message in that chat newer than the ask: the calls already in the window, or `read_messages` on that chat with `types: ["call"]`. A call whose `call.outcome` is `answered` moves the item to *Probably handled by call*, carrying when it was and how long it ran, and ending in a question, because the call may have been about something else:
32
+
33
+ `Ana — asked about Thursday 10:00; you spoke for 6 min on Tue 14:10. Confirm?`
34
+
35
+ Missed, rejected and unanswered calls are evidence of nothing, and those items stay in *Needs you*.
36
+
28
37
  ## Report
29
38
 
30
39
  ```
@@ -33,8 +42,13 @@ Needs you (3)
33
42
  2. Bloc 12 group — Mihai needs your vote on the roof quote by Friday. 1d ago.
34
43
  3. Dan — sent the contract PDF, waiting for your comments. 2d ago.
35
44
 
45
+ Probably handled by call (1)
46
+ 1. Ana — asked about Thursday 10:00; you spoke for 6 min on Tue 14:10. Confirm?
47
+
36
48
  FYI: Curier (delivered), Mama (photos), Team (retro moved to Tuesday).
37
49
  Noise: 4 promo chats.
38
50
  ```
39
51
 
52
+ End the report with: *Handled any of these by phone outside WhatsApp? Tell me and I will drop them.* wazap sees WhatsApp calls and never cellular ones, so a call from the phone's own dialler leaves no trace here. Whatever the user answers is authoritative for the rest of the session: drop what they name and do not raise it again.
53
+
40
54
  One line per item: who, what they want, how old. Include the `chat_id` only if the user is likely to act through another tool next. Offer to draft replies only for *Needs you* items; drafting and sending belong to the `whatsapp-send` skill.