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 +8 -2
- package/dist/calls.js +153 -0
- package/dist/messages.js +90 -0
- package/dist/tools.js +18 -5
- package/dist/wa-types.js +19 -1
- package/dist/whatsapp.js +143 -16
- package/package.json +1 -1
- package/skills/whatsapp-groups/SKILL.md +1 -1
- package/skills/whatsapp-inbox/SKILL.md +14 -0
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
|
-
|
|
2
|
+
/** The zod enum the tools expose derives from this, so the two cannot drift. */
|
|
3
|
+
export const MESSAGE_TYPES = [
|
|
4
|
+
"text",
|
|
5
|
+
"image",
|
|
6
|
+
"video",
|
|
7
|
+
"audio",
|
|
8
|
+
"voice",
|
|
9
|
+
"document",
|
|
10
|
+
"sticker",
|
|
11
|
+
"location",
|
|
12
|
+
"contact",
|
|
13
|
+
"poll",
|
|
14
|
+
"reaction",
|
|
15
|
+
"deleted",
|
|
16
|
+
"view_once",
|
|
17
|
+
"call",
|
|
18
|
+
"system",
|
|
19
|
+
"unknown",
|
|
20
|
+
];
|
package/dist/whatsapp.js
CHANGED
|
@@ -10,11 +10,12 @@ import { isAbsolute, join } from "node:path";
|
|
|
10
10
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
11
11
|
import makeWASocket, { ALL_WA_PATCH_NAMES, Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
|
|
12
12
|
import { readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
13
|
+
import { CallTracker, callMessage, isTrackedCall } from "./calls.js";
|
|
13
14
|
import { BAILEYS_VERSION, paths, WAZAP_VERSION } from "./config.js";
|
|
14
15
|
import { asWazapError, RELINK_FIX, RESET_FIX, WazapError } from "./errors.js";
|
|
15
16
|
import { isGroupId, isNoiseJid, resolveChatId } from "./ids.js";
|
|
16
17
|
import { log, logError } from "./logger.js";
|
|
17
|
-
import { buildMessageView, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
|
|
18
|
+
import { buildMessageView, callInfo, isCallPlaceholder, isControlMessage, isStubEvent, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, messageType, protoNumber, } from "./messages.js";
|
|
18
19
|
/** Reconnect pacing. A closed socket used to be retried instantly, which turns
|
|
19
20
|
* any persistent rejection into a login storm — WhatsApp answers that by
|
|
20
21
|
* throttling the account and refusing to link *any* new device to it, phone
|
|
@@ -35,6 +36,10 @@ const STALE_INBOUND_MS = 24 * 3_600_000;
|
|
|
35
36
|
const MAX_MESSAGES_PER_CHAT = 1_000;
|
|
36
37
|
const PERSIST_MESSAGES_PER_CHAT = 120;
|
|
37
38
|
const STORE_SAVE_DEBOUNCE_MS = 20_000;
|
|
39
|
+
const CALL_SWEEP_MS = 30_000;
|
|
40
|
+
/** The same call reaches the store up to three ways; only nearness in time tells them apart. */
|
|
41
|
+
const CALL_DEDUPE_WINDOW_MS = 60_000;
|
|
42
|
+
const CALL_DEDUPE_SCAN = 20;
|
|
38
43
|
const HISTORY_STORE_CAP_PER_CHAT = 2_000;
|
|
39
44
|
const DIR_MODE = 0o700;
|
|
40
45
|
const FILE_MODE = 0o600;
|
|
@@ -121,13 +126,35 @@ class Store {
|
|
|
121
126
|
ring.sort((a, b) => this.seconds(a) - this.seconds(b));
|
|
122
127
|
while (ring.length > MAX_MESSAGES_PER_CHAT) {
|
|
123
128
|
const dropped = ring.shift();
|
|
124
|
-
if (dropped)
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
if (dropped)
|
|
130
|
+
this.forget(dropped);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** The tail of a chat, newest first. */
|
|
134
|
+
recent(chatJid, count) {
|
|
135
|
+
const ring = this.byChat.get(chatJid) ?? [];
|
|
136
|
+
const tail = [];
|
|
137
|
+
for (let i = ring.length - 1; i >= 0 && tail.length < count; i--) {
|
|
138
|
+
const sid = ring[i];
|
|
139
|
+
const raw = this.messages.get(sid);
|
|
140
|
+
if (raw)
|
|
141
|
+
tail.push({ sid, raw });
|
|
130
142
|
}
|
|
143
|
+
return tail;
|
|
144
|
+
}
|
|
145
|
+
/** Forget one message entirely, its place in the chat included. */
|
|
146
|
+
dropMessage(sid) {
|
|
147
|
+
const ring = this.byChat.get(this.chatOf.get(sid) ?? "");
|
|
148
|
+
const at = ring?.indexOf(sid) ?? -1;
|
|
149
|
+
if (ring && at !== -1)
|
|
150
|
+
ring.splice(at, 1);
|
|
151
|
+
this.forget(sid);
|
|
152
|
+
}
|
|
153
|
+
forget(sid) {
|
|
154
|
+
this.messages.delete(sid);
|
|
155
|
+
this.chatOf.delete(sid);
|
|
156
|
+
this.edited.delete(sid);
|
|
157
|
+
this.reactions.delete(sid);
|
|
131
158
|
}
|
|
132
159
|
reactionsFor(sid) {
|
|
133
160
|
const map = this.reactions.get(sid);
|
|
@@ -227,6 +254,7 @@ export class WhatsAppService {
|
|
|
227
254
|
historyWaiters = [];
|
|
228
255
|
storeDirty = false;
|
|
229
256
|
storeSaveTimer = null;
|
|
257
|
+
callSweepTimer = null;
|
|
230
258
|
persistedLoaded = false;
|
|
231
259
|
contactResyncTried = false;
|
|
232
260
|
blocked = new Set();
|
|
@@ -238,6 +266,7 @@ export class WhatsAppService {
|
|
|
238
266
|
/** The same, for naming only, and it holds more. See `learnLidPhone`. */
|
|
239
267
|
lidPhones = new Map();
|
|
240
268
|
store = new Store();
|
|
269
|
+
calls = new CallTracker();
|
|
241
270
|
paths;
|
|
242
271
|
constructor(config) {
|
|
243
272
|
this.config = config;
|
|
@@ -289,6 +318,7 @@ export class WhatsAppService {
|
|
|
289
318
|
this.storeSaveTimer = null;
|
|
290
319
|
this.reconnectTimer = null;
|
|
291
320
|
this.syncDeadline = null;
|
|
321
|
+
this.stopCallSweep();
|
|
292
322
|
this.releaseWaiters();
|
|
293
323
|
await this.flushStore();
|
|
294
324
|
this.teardownSocket();
|
|
@@ -361,7 +391,7 @@ export class WhatsAppService {
|
|
|
361
391
|
return this.synced(chats);
|
|
362
392
|
});
|
|
363
393
|
}
|
|
364
|
-
readMessages(chatId, limit, before) {
|
|
394
|
+
readMessages(chatId, limit, before, types) {
|
|
365
395
|
return this.guarded(async () => {
|
|
366
396
|
const sock = this.ensureConnected();
|
|
367
397
|
const jid = this.resolveId(chatId);
|
|
@@ -369,19 +399,19 @@ export class WhatsAppService {
|
|
|
369
399
|
await this.learnParticipants(jid);
|
|
370
400
|
await this.learnLidPhones([jid]);
|
|
371
401
|
if (before === undefined) {
|
|
372
|
-
const ring = this.store.byChat.get(jid) ?? [];
|
|
402
|
+
const ring = this.ofTypes(this.store.byChat.get(jid) ?? [], types);
|
|
373
403
|
return this.synced(this.viewsFor(ring.slice(-limit), jid));
|
|
374
404
|
}
|
|
375
405
|
const anchor = this.messageOrThrow(before);
|
|
376
|
-
let older = this.olderThan(jid, before, limit);
|
|
406
|
+
let older = this.olderThan(jid, before, limit, types);
|
|
377
407
|
if (older.length === 0) {
|
|
378
408
|
await this.fetchOlder(sock, anchor, limit);
|
|
379
|
-
older = this.olderThan(jid, before, limit);
|
|
409
|
+
older = this.olderThan(jid, before, limit, types);
|
|
380
410
|
}
|
|
381
411
|
return this.synced(this.viewsFor(older, jid));
|
|
382
412
|
});
|
|
383
413
|
}
|
|
384
|
-
getRecentMessages(hours, filter, includeSystem = false) {
|
|
414
|
+
getRecentMessages(hours, filter, includeSystem = false, types) {
|
|
385
415
|
return this.guarded(async () => {
|
|
386
416
|
this.ensureConnected();
|
|
387
417
|
await this.waitForSync();
|
|
@@ -402,7 +432,7 @@ export class WhatsAppService {
|
|
|
402
432
|
});
|
|
403
433
|
if (recent.length === 0)
|
|
404
434
|
continue;
|
|
405
|
-
const messages = this.viewsFor(recent, jid).filter((view) => includeSystem || view.type !== "system");
|
|
435
|
+
const messages = this.viewsFor(this.ofTypes(recent, types), jid).filter((view) => includeSystem || view.type !== "system");
|
|
406
436
|
if (messages.length === 0)
|
|
407
437
|
continue;
|
|
408
438
|
conversations.push({
|
|
@@ -838,6 +868,18 @@ export class WhatsAppService {
|
|
|
838
868
|
this.markSyncDone();
|
|
839
869
|
this.markStoreDirty();
|
|
840
870
|
});
|
|
871
|
+
sock.ev.on("call", ([call]) => {
|
|
872
|
+
if (generation !== this.generation || !call)
|
|
873
|
+
return;
|
|
874
|
+
// WhatsApp addresses a call node by LID as often as by number, and ownJid
|
|
875
|
+
// is only ever the number, so an outgoing call reads as incoming unless
|
|
876
|
+
// the two are brought into the same form first.
|
|
877
|
+
const from = this.canonical(call.from);
|
|
878
|
+
const entry = this.calls.observe({ ...call, from }, this.ownJid(), Date.now());
|
|
879
|
+
if (entry)
|
|
880
|
+
this.storeCall(entry);
|
|
881
|
+
this.armCallSweep();
|
|
882
|
+
});
|
|
841
883
|
sock.ev.on("lid-mapping.update", (mapping) => this.learnLid(mapping.lid, mapping.pn));
|
|
842
884
|
sock.ev.on("chats.upsert", (chats) => {
|
|
843
885
|
for (const chat of chats)
|
|
@@ -1364,12 +1406,26 @@ export class WhatsAppService {
|
|
|
1364
1406
|
viewsFor(sids, chatJid) {
|
|
1365
1407
|
return sids.filter((sid) => this.store.messages.has(sid)).map((sid) => this.viewOf(sid, chatJid));
|
|
1366
1408
|
}
|
|
1367
|
-
|
|
1409
|
+
/** Absent and empty both mean every type: narrowing is opt-in, never a default. */
|
|
1410
|
+
ofTypes(sids, types) {
|
|
1411
|
+
if (types === undefined || types.length === 0)
|
|
1412
|
+
return sids;
|
|
1413
|
+
return sids.filter((sid) => {
|
|
1414
|
+
const raw = this.store.messages.get(sid);
|
|
1415
|
+
return raw !== undefined && types.includes(messageType(raw));
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* The anchor is found in the unfiltered ring, so paging never depends on the
|
|
1420
|
+
* filter, and `limit` then counts messages the caller asked for rather than
|
|
1421
|
+
* messages we are about to throw away.
|
|
1422
|
+
*/
|
|
1423
|
+
olderThan(chatJid, before, limit, types) {
|
|
1368
1424
|
const ring = this.store.byChat.get(chatJid) ?? [];
|
|
1369
1425
|
const at = ring.indexOf(before);
|
|
1370
1426
|
if (at <= 0)
|
|
1371
1427
|
return [];
|
|
1372
|
-
return ring.slice(
|
|
1428
|
+
return this.ofTypes(ring.slice(0, at), types).slice(-limit);
|
|
1373
1429
|
}
|
|
1374
1430
|
async fetchOlder(sock, anchor, limit) {
|
|
1375
1431
|
const seconds = Math.floor(messageTimestampMs(anchor) / 1000);
|
|
@@ -1499,11 +1555,74 @@ export class WhatsAppService {
|
|
|
1499
1555
|
if (isNoiseJid(jid) || isControlMessage(raw))
|
|
1500
1556
|
continue;
|
|
1501
1557
|
this.learnPushName(raw, jid);
|
|
1502
|
-
|
|
1558
|
+
const sid = messageIdFor(raw.key, jid);
|
|
1559
|
+
if (!this.keepOverEarlierCall(raw, jid, sid))
|
|
1560
|
+
continue;
|
|
1561
|
+
this.store.putMessage(sid, jid, raw);
|
|
1503
1562
|
stored.push(raw);
|
|
1504
1563
|
}
|
|
1505
1564
|
return stored;
|
|
1506
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* One call can reach the store three ways: wazap's own tracker, the stub
|
|
1568
|
+
* baileys synthesises on a timeout, and WhatsApp's later call-log message.
|
|
1569
|
+
* Each carries a different id, so only nearness in time pairs them up, and
|
|
1570
|
+
* whichever says more about the call is the one worth keeping. The history
|
|
1571
|
+
* reload runs it too: the JSONL still holds the line the loser wrote before
|
|
1572
|
+
* it was dropped, and a restart would otherwise bring the pair back.
|
|
1573
|
+
*/
|
|
1574
|
+
keepOverEarlierCall(raw, chatJid, sid) {
|
|
1575
|
+
const info = callInfo(raw);
|
|
1576
|
+
if (!info)
|
|
1577
|
+
return true;
|
|
1578
|
+
const at = messageTimestampMs(raw);
|
|
1579
|
+
for (const known of this.store.recent(chatJid, CALL_DEDUPE_SCAN)) {
|
|
1580
|
+
if (known.sid === sid)
|
|
1581
|
+
continue;
|
|
1582
|
+
const other = callInfo(known.raw);
|
|
1583
|
+
if (!other)
|
|
1584
|
+
continue;
|
|
1585
|
+
// A redial inside the window is two calls, and wazap knows it built both.
|
|
1586
|
+
if (isTrackedCall(raw) && isTrackedCall(known.raw))
|
|
1587
|
+
continue;
|
|
1588
|
+
if (Math.abs(messageTimestampMs(known.raw) - at) > CALL_DEDUPE_WINDOW_MS)
|
|
1589
|
+
continue;
|
|
1590
|
+
if (callDetail(raw, info) <= callDetail(known.raw, other))
|
|
1591
|
+
return false;
|
|
1592
|
+
this.store.dropMessage(known.sid);
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
/** A live call goes in the way any message does, so everything downstream carries it. */
|
|
1598
|
+
storeCall(entry) {
|
|
1599
|
+
const stored = this.ingestMessages([callMessage(entry)]);
|
|
1600
|
+
if (stored.length === 0)
|
|
1601
|
+
return;
|
|
1602
|
+
void this.appendHistory(stored);
|
|
1603
|
+
this.markStoreDirty();
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Only while a call is in flight: a call whose terminal event never arrives
|
|
1607
|
+
* would otherwise sit pending forever, and a timer with nothing to do would
|
|
1608
|
+
* otherwise keep ticking for the life of the process.
|
|
1609
|
+
*/
|
|
1610
|
+
armCallSweep() {
|
|
1611
|
+
if (this.callSweepTimer || this.calls.pending === 0)
|
|
1612
|
+
return;
|
|
1613
|
+
this.callSweepTimer = setInterval(() => {
|
|
1614
|
+
for (const entry of this.calls.expire(Date.now()))
|
|
1615
|
+
this.storeCall(entry);
|
|
1616
|
+
if (this.calls.pending === 0)
|
|
1617
|
+
this.stopCallSweep();
|
|
1618
|
+
}, CALL_SWEEP_MS);
|
|
1619
|
+
this.callSweepTimer.unref();
|
|
1620
|
+
}
|
|
1621
|
+
stopCallSweep() {
|
|
1622
|
+
if (this.callSweepTimer)
|
|
1623
|
+
clearInterval(this.callSweepTimer);
|
|
1624
|
+
this.callSweepTimer = null;
|
|
1625
|
+
}
|
|
1507
1626
|
learnPushName(raw, chatJid) {
|
|
1508
1627
|
const name = raw.pushName?.trim();
|
|
1509
1628
|
if (!name || raw.key.fromMe)
|
|
@@ -1624,6 +1743,8 @@ export class WhatsAppService {
|
|
|
1624
1743
|
const jid = this.canonical(raw.key.remoteJid);
|
|
1625
1744
|
if (isNoiseJid(jid) || isControlMessage(raw))
|
|
1626
1745
|
continue;
|
|
1746
|
+
if (!this.keepOverEarlierCall(raw, jid, record.sid))
|
|
1747
|
+
continue;
|
|
1627
1748
|
this.store.putMessage(record.sid, jid, raw);
|
|
1628
1749
|
loaded++;
|
|
1629
1750
|
}
|
|
@@ -1674,6 +1795,12 @@ const ADMIN_ACTIONS = new Set([
|
|
|
1674
1795
|
const PARTICIPANT_ACTIONS = new Set(["add", "remove", "promote", "demote"]);
|
|
1675
1796
|
/** WhatsApp answers "cannot add, invite them instead" with these codes. */
|
|
1676
1797
|
const INVITE_NEEDED_CODES = new Set(["403", "409"]);
|
|
1798
|
+
/** How much a call message says. A duration is the most it can carry. */
|
|
1799
|
+
function callDetail(raw, info) {
|
|
1800
|
+
if (info.duration_seconds !== undefined)
|
|
1801
|
+
return 2;
|
|
1802
|
+
return isCallPlaceholder(raw) ? 0 : 1;
|
|
1803
|
+
}
|
|
1677
1804
|
function lidKey(lid) {
|
|
1678
1805
|
return `${jidNormalizedUser(lid).split("@")[0]}@lid`;
|
|
1679
1806
|
}
|
package/package.json
CHANGED
|
@@ -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.
|