wazap-mcp 0.9.0
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/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/auth-state.js +116 -0
- package/dist/banner.js +7 -0
- package/dist/cli.js +372 -0
- package/dist/config.js +138 -0
- package/dist/connect.js +222 -0
- package/dist/doctor.js +114 -0
- package/dist/errors.js +48 -0
- package/dist/ids.js +50 -0
- package/dist/index.js +88 -0
- package/dist/lock.js +42 -0
- package/dist/logger.js +16 -0
- package/dist/messages.js +281 -0
- package/dist/ratelimit.js +35 -0
- package/dist/server.js +133 -0
- package/dist/settings.js +74 -0
- package/dist/tools.js +591 -0
- package/dist/wa-types.js +2 -0
- package/dist/whatsapp.js +1514 -0
- package/package.json +60 -0
package/dist/whatsapp.js
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp service over Baileys. Baileys emits raw events rather than exposing
|
|
3
|
+
* a queryable store, so this keeps a small in-memory store (chats, contacts,
|
|
4
|
+
* messages by id) fed from those events, optionally persisted under the data
|
|
5
|
+
* dir so a restart does not start blind.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
10
|
+
import makeWASocket, { Browsers, DisconnectReason, downloadMediaMessage, jidNormalizedUser, proto, } from "baileys";
|
|
11
|
+
import { readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
|
|
12
|
+
import { BAILEYS_VERSION, paths, WAZAP_VERSION } from "./config.js";
|
|
13
|
+
import { asWazapError, RELINK_FIX, RESET_FIX, WazapError } from "./errors.js";
|
|
14
|
+
import { isGroupId, resolveChatId } from "./ids.js";
|
|
15
|
+
import { log, logError } from "./logger.js";
|
|
16
|
+
import { buildMessageView, isoWithOffset, mediaInfo, messageIdFor, messageText, messageTimestampMs, protoNumber, } from "./messages.js";
|
|
17
|
+
/** Reconnect pacing. A closed socket used to be retried instantly, which turns
|
|
18
|
+
* any persistent rejection into a login storm — WhatsApp answers that by
|
|
19
|
+
* throttling the account and refusing to link *any* new device to it, phone
|
|
20
|
+
* included. Retries are spaced, jittered and capped; past the cap we stop and
|
|
21
|
+
* wait for a human instead of hammering. */
|
|
22
|
+
const RECONNECT_BASE_MS = 2_000;
|
|
23
|
+
const RECONNECT_MAX_MS = 5 * 60_000;
|
|
24
|
+
const RECONNECT_MAX_ATTEMPTS = 10;
|
|
25
|
+
const SYNC_WAIT_MS = 10_000;
|
|
26
|
+
const HISTORY_FETCH_WAIT_MS = 5_000;
|
|
27
|
+
const INLINE_IMAGE_MAX_BYTES = 1_000_000;
|
|
28
|
+
const MAX_TEXT_CHARS = 65_536;
|
|
29
|
+
const MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
|
30
|
+
const EDIT_WINDOW_MS = 15 * 60_000;
|
|
31
|
+
const RETRACT_WINDOW_MS = 2 * 24 * 3_600_000;
|
|
32
|
+
const MAX_GROUP_PARTICIPANTS = 500;
|
|
33
|
+
const STALE_INBOUND_MS = 24 * 3_600_000;
|
|
34
|
+
const MAX_MESSAGES_PER_CHAT = 1_000;
|
|
35
|
+
const PERSIST_MESSAGES_PER_CHAT = 120;
|
|
36
|
+
const STORE_SAVE_DEBOUNCE_MS = 20_000;
|
|
37
|
+
const HISTORY_STORE_CAP_PER_CHAT = 2_000;
|
|
38
|
+
const DIR_MODE = 0o700;
|
|
39
|
+
const FILE_MODE = 0o600;
|
|
40
|
+
/** Baileys logs at info level to stdout by default, which corrupts the MCP
|
|
41
|
+
* JSON-RPC stream on stdio. */
|
|
42
|
+
const silentLogger = {
|
|
43
|
+
level: "silent",
|
|
44
|
+
child: () => silentLogger,
|
|
45
|
+
trace: () => { },
|
|
46
|
+
debug: () => { },
|
|
47
|
+
info: () => { },
|
|
48
|
+
warn: () => { },
|
|
49
|
+
error: () => { },
|
|
50
|
+
};
|
|
51
|
+
/** In-memory state fed from Baileys events, keyed by canonical jid. */
|
|
52
|
+
class Store {
|
|
53
|
+
chats = new Map();
|
|
54
|
+
contacts = new Map();
|
|
55
|
+
messages = new Map();
|
|
56
|
+
chatOf = new Map();
|
|
57
|
+
byChat = new Map();
|
|
58
|
+
edited = new Set();
|
|
59
|
+
reactions = new Map();
|
|
60
|
+
seconds(sid) {
|
|
61
|
+
const raw = this.messages.get(sid);
|
|
62
|
+
return raw ? messageTimestampMs(raw) / 1000 : 0;
|
|
63
|
+
}
|
|
64
|
+
putMessage(sid, chatJid, raw) {
|
|
65
|
+
const known = this.messages.has(sid);
|
|
66
|
+
this.messages.set(sid, raw);
|
|
67
|
+
this.chatOf.set(sid, chatJid);
|
|
68
|
+
let ring = this.byChat.get(chatJid);
|
|
69
|
+
if (!ring) {
|
|
70
|
+
ring = [];
|
|
71
|
+
this.byChat.set(chatJid, ring);
|
|
72
|
+
}
|
|
73
|
+
if (known && ring.includes(sid))
|
|
74
|
+
return;
|
|
75
|
+
// Live messages arrive newest-last, so appending is enough; a history sync
|
|
76
|
+
// delivers older ones out of order and only then is a re-sort needed.
|
|
77
|
+
const ts = messageTimestampMs(raw) / 1000;
|
|
78
|
+
const last = ring.length > 0 ? this.seconds(ring[ring.length - 1]) : Number.NEGATIVE_INFINITY;
|
|
79
|
+
ring.push(sid);
|
|
80
|
+
if (ts < last)
|
|
81
|
+
ring.sort((a, b) => this.seconds(a) - this.seconds(b));
|
|
82
|
+
while (ring.length > MAX_MESSAGES_PER_CHAT) {
|
|
83
|
+
const dropped = ring.shift();
|
|
84
|
+
if (dropped) {
|
|
85
|
+
this.messages.delete(dropped);
|
|
86
|
+
this.chatOf.delete(dropped);
|
|
87
|
+
this.edited.delete(dropped);
|
|
88
|
+
this.reactions.delete(dropped);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
reactionsFor(sid) {
|
|
93
|
+
const map = this.reactions.get(sid);
|
|
94
|
+
if (!map)
|
|
95
|
+
return [];
|
|
96
|
+
return [...map].map(([sender, emoji]) => ({ emoji, sender }));
|
|
97
|
+
}
|
|
98
|
+
serialize() {
|
|
99
|
+
const snapshot = { v: 1, chats: {}, contacts: {}, messages: {}, byChat: {} };
|
|
100
|
+
for (const [jid, chat] of this.chats) {
|
|
101
|
+
const encoded = encode(() => proto.Conversation.encode(chat).finish());
|
|
102
|
+
if (encoded)
|
|
103
|
+
snapshot.chats[jid] = encoded;
|
|
104
|
+
}
|
|
105
|
+
for (const [jid, contact] of this.contacts)
|
|
106
|
+
snapshot.contacts[jid] = contact;
|
|
107
|
+
const keep = new Set();
|
|
108
|
+
for (const [jid, ring] of this.byChat) {
|
|
109
|
+
const capped = ring.slice(-PERSIST_MESSAGES_PER_CHAT);
|
|
110
|
+
snapshot.byChat[jid] = capped;
|
|
111
|
+
for (const sid of capped)
|
|
112
|
+
keep.add(sid);
|
|
113
|
+
}
|
|
114
|
+
for (const sid of keep) {
|
|
115
|
+
const raw = this.messages.get(sid);
|
|
116
|
+
if (!raw)
|
|
117
|
+
continue;
|
|
118
|
+
const encoded = encode(() => proto.WebMessageInfo.encode(raw).finish());
|
|
119
|
+
if (encoded)
|
|
120
|
+
snapshot.messages[sid] = encoded;
|
|
121
|
+
}
|
|
122
|
+
return snapshot;
|
|
123
|
+
}
|
|
124
|
+
hydrate(snapshot) {
|
|
125
|
+
if (snapshot?.v !== 1)
|
|
126
|
+
return;
|
|
127
|
+
for (const [jid, b64] of Object.entries(snapshot.chats ?? {})) {
|
|
128
|
+
const chat = decodeChat(b64);
|
|
129
|
+
if (chat)
|
|
130
|
+
this.chats.set(jid, chat);
|
|
131
|
+
}
|
|
132
|
+
for (const [jid, contact] of Object.entries(snapshot.contacts ?? {}))
|
|
133
|
+
this.contacts.set(jid, contact);
|
|
134
|
+
for (const [sid, b64] of Object.entries(snapshot.messages ?? {})) {
|
|
135
|
+
const raw = decodeMessage(b64);
|
|
136
|
+
if (raw)
|
|
137
|
+
this.messages.set(sid, raw);
|
|
138
|
+
}
|
|
139
|
+
for (const [jid, ring] of Object.entries(snapshot.byChat ?? {})) {
|
|
140
|
+
const present = ring.filter((sid) => this.messages.has(sid));
|
|
141
|
+
this.byChat.set(jid, present);
|
|
142
|
+
for (const sid of present)
|
|
143
|
+
this.chatOf.set(sid, jid);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The browser identity sent at handshake. WhatsApp closes the socket with 428
|
|
149
|
+
* before offering a QR for Browsers.macOS("Desktop") (verified 2026-08-22 on
|
|
150
|
+
* baileys 7.0.0-rc14); "Chrome" is accepted.
|
|
151
|
+
*/
|
|
152
|
+
export const WA_BROWSER = Browsers.macOS("Chrome");
|
|
153
|
+
export class WhatsAppService {
|
|
154
|
+
config;
|
|
155
|
+
sockClient = null;
|
|
156
|
+
saveCreds = null;
|
|
157
|
+
reconnectAttempts = 0;
|
|
158
|
+
reconnectTimer = null;
|
|
159
|
+
starting = false;
|
|
160
|
+
stopped = false;
|
|
161
|
+
/** Bumped per socket, so events from a superseded socket are ignored. */
|
|
162
|
+
generation = 0;
|
|
163
|
+
status = "connecting";
|
|
164
|
+
lastError = null;
|
|
165
|
+
account = null;
|
|
166
|
+
lastInboundAt = null;
|
|
167
|
+
initialSyncDone = false;
|
|
168
|
+
syncDeadline = null;
|
|
169
|
+
syncWaiters = [];
|
|
170
|
+
historyWaiters = [];
|
|
171
|
+
storeDirty = false;
|
|
172
|
+
storeSaveTimer = null;
|
|
173
|
+
persistedLoaded = false;
|
|
174
|
+
blocked = new Set();
|
|
175
|
+
groupCache = new Map();
|
|
176
|
+
/** `<user>@lid` to the phone-number jid, so ids we hand out stay canonical. */
|
|
177
|
+
lidToPn = new Map();
|
|
178
|
+
store = new Store();
|
|
179
|
+
paths;
|
|
180
|
+
constructor(config) {
|
|
181
|
+
this.config = config;
|
|
182
|
+
this.paths = paths(config.dataDir);
|
|
183
|
+
}
|
|
184
|
+
async start() {
|
|
185
|
+
if (this.stopped || this.starting)
|
|
186
|
+
return;
|
|
187
|
+
this.starting = true;
|
|
188
|
+
try {
|
|
189
|
+
const linked = this.readAccount();
|
|
190
|
+
if (linked === "corrupt" || linked === null)
|
|
191
|
+
return;
|
|
192
|
+
this.account = linked;
|
|
193
|
+
await this.loadPersisted();
|
|
194
|
+
let state;
|
|
195
|
+
try {
|
|
196
|
+
({ state, saveCreds: this.saveCreds } = await useAtomicAuthState(this.paths.authDir));
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
this.markCorrupt(err);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.teardownSocket();
|
|
203
|
+
this.initialSyncDone = false;
|
|
204
|
+
this.status = "connecting";
|
|
205
|
+
const generation = ++this.generation;
|
|
206
|
+
const sock = makeWASocket({
|
|
207
|
+
auth: state,
|
|
208
|
+
logger: silentLogger,
|
|
209
|
+
browser: WA_BROWSER,
|
|
210
|
+
syncFullHistory: this.config.syncFullHistory,
|
|
211
|
+
markOnlineOnConnect: false,
|
|
212
|
+
generateHighQualityLinkPreview: false,
|
|
213
|
+
});
|
|
214
|
+
this.sockClient = sock;
|
|
215
|
+
this.wireEvents(sock, generation);
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
this.starting = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async stop() {
|
|
222
|
+
this.stopped = true;
|
|
223
|
+
for (const timer of [this.storeSaveTimer, this.reconnectTimer, this.syncDeadline]) {
|
|
224
|
+
if (timer)
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
}
|
|
227
|
+
this.storeSaveTimer = null;
|
|
228
|
+
this.reconnectTimer = null;
|
|
229
|
+
this.syncDeadline = null;
|
|
230
|
+
this.releaseWaiters();
|
|
231
|
+
await this.flushStore();
|
|
232
|
+
this.teardownSocket();
|
|
233
|
+
}
|
|
234
|
+
getStatus() {
|
|
235
|
+
const info = {
|
|
236
|
+
status: this.status,
|
|
237
|
+
sync: this.syncState(),
|
|
238
|
+
account: this.account,
|
|
239
|
+
last_message_received_at: this.lastInboundAt === null ? null : isoWithOffset(this.lastInboundAt),
|
|
240
|
+
reconnect_attempts: this.reconnectAttempts,
|
|
241
|
+
wazap_version: WAZAP_VERSION,
|
|
242
|
+
baileys_version: BAILEYS_VERSION,
|
|
243
|
+
data_dir: this.config.dataDir,
|
|
244
|
+
read_only: this.config.readOnly,
|
|
245
|
+
rate_limit: this.config.rateLimitPerMinute,
|
|
246
|
+
last_error: this.lastError,
|
|
247
|
+
};
|
|
248
|
+
const stale = this.lastInboundAt !== null && Date.now() - this.lastInboundAt > STALE_INBOUND_MS;
|
|
249
|
+
if (this.status === "connected" && stale) {
|
|
250
|
+
info.hint = "No messages received for 24h; the phone may be offline.";
|
|
251
|
+
}
|
|
252
|
+
return info;
|
|
253
|
+
}
|
|
254
|
+
listChats(filter, limit) {
|
|
255
|
+
return this.guarded(async () => {
|
|
256
|
+
this.ensureConnected();
|
|
257
|
+
await this.waitForSync();
|
|
258
|
+
const chats = [...this.store.chats.values()]
|
|
259
|
+
.filter((chat) => this.matchesChatFilter(chat, filter))
|
|
260
|
+
.sort((a, b) => this.chatActivity(b) - this.chatActivity(a))
|
|
261
|
+
.slice(0, limit)
|
|
262
|
+
.map((chat) => this.chatSummary(chat));
|
|
263
|
+
return this.synced(chats);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
readMessages(chatId, limit, before) {
|
|
267
|
+
return this.guarded(async () => {
|
|
268
|
+
const sock = this.ensureConnected();
|
|
269
|
+
const jid = this.resolveId(chatId);
|
|
270
|
+
await this.waitForSync();
|
|
271
|
+
if (before === undefined) {
|
|
272
|
+
const ring = this.store.byChat.get(jid) ?? [];
|
|
273
|
+
return this.synced(this.viewsFor(ring.slice(-limit), jid));
|
|
274
|
+
}
|
|
275
|
+
const anchor = this.messageOrThrow(before);
|
|
276
|
+
let older = this.olderThan(jid, before, limit);
|
|
277
|
+
if (older.length === 0) {
|
|
278
|
+
await this.fetchOlder(sock, anchor, limit);
|
|
279
|
+
older = this.olderThan(jid, before, limit);
|
|
280
|
+
}
|
|
281
|
+
return this.synced(this.viewsFor(older, jid));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
getRecentMessages(hours, filter) {
|
|
285
|
+
return this.guarded(async () => {
|
|
286
|
+
this.ensureConnected();
|
|
287
|
+
await this.waitForSync();
|
|
288
|
+
const cutoff = Date.now() - hours * 3_600_000;
|
|
289
|
+
const conversations = [];
|
|
290
|
+
for (const [jid, ring] of this.store.byChat) {
|
|
291
|
+
const chat = this.store.chats.get(jid);
|
|
292
|
+
if (chat && !this.matchesChatFilter(chat, filter))
|
|
293
|
+
continue;
|
|
294
|
+
if (!chat && (filter === "unread" || filter === (isGroupId(jid) ? "individual" : "groups")))
|
|
295
|
+
continue;
|
|
296
|
+
const recent = ring.filter((sid) => {
|
|
297
|
+
const raw = this.store.messages.get(sid);
|
|
298
|
+
return raw !== undefined && messageTimestampMs(raw) >= cutoff;
|
|
299
|
+
});
|
|
300
|
+
if (recent.length === 0)
|
|
301
|
+
continue;
|
|
302
|
+
const messages = this.viewsFor(recent, jid);
|
|
303
|
+
const last = messages[messages.length - 1];
|
|
304
|
+
conversations.push({
|
|
305
|
+
chat_id: jid,
|
|
306
|
+
chat_name: this.nameFor(jid),
|
|
307
|
+
type: isGroupId(jid) ? "group" : "individual",
|
|
308
|
+
last_activity: last ? last.timestamp : isoWithOffset(cutoff),
|
|
309
|
+
messages,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
conversations.sort((a, b) => b.last_activity.localeCompare(a.last_activity));
|
|
313
|
+
return this.synced(conversations);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
searchMessages(query, chatId, limit) {
|
|
317
|
+
return this.guarded(async () => {
|
|
318
|
+
this.ensureConnected();
|
|
319
|
+
await this.waitForSync();
|
|
320
|
+
const needle = query.trim().toLowerCase();
|
|
321
|
+
const scope = chatId === undefined ? undefined : this.resolveId(chatId);
|
|
322
|
+
const hits = [];
|
|
323
|
+
for (const [sid, raw] of this.store.messages) {
|
|
324
|
+
const jid = this.store.chatOf.get(sid);
|
|
325
|
+
if (!jid || (scope !== undefined && jid !== scope))
|
|
326
|
+
continue;
|
|
327
|
+
if (needle && !messageText(raw).toLowerCase().includes(needle))
|
|
328
|
+
continue;
|
|
329
|
+
hits.push({ sid, jid, at: messageTimestampMs(raw) });
|
|
330
|
+
}
|
|
331
|
+
hits.sort((a, b) => b.at - a.at);
|
|
332
|
+
return this.synced(hits.slice(0, limit).map((hit) => this.viewOf(hit.sid, hit.jid)));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
getMessage(messageId) {
|
|
336
|
+
return this.guarded(async () => {
|
|
337
|
+
this.ensureConnected();
|
|
338
|
+
this.messageOrThrow(messageId);
|
|
339
|
+
return this.viewOf(messageId, this.store.chatOf.get(messageId) ?? "");
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
searchContacts(query, limit) {
|
|
343
|
+
return this.guarded(async () => {
|
|
344
|
+
this.ensureConnected();
|
|
345
|
+
await this.waitForSync();
|
|
346
|
+
const needle = query.trim().toLowerCase();
|
|
347
|
+
const digits = needle.replace(/\D/g, "");
|
|
348
|
+
const matches = [];
|
|
349
|
+
for (const [jid, contact] of this.store.contacts) {
|
|
350
|
+
const name = (contact.name ?? "").toLowerCase();
|
|
351
|
+
const notify = (contact.notify ?? "").toLowerCase();
|
|
352
|
+
const number = jid.split("@")[0] ?? "";
|
|
353
|
+
const hit = needle === "" ||
|
|
354
|
+
name.includes(needle) ||
|
|
355
|
+
notify.includes(needle) ||
|
|
356
|
+
(digits.length >= 5 && number.includes(digits));
|
|
357
|
+
if (!hit)
|
|
358
|
+
continue;
|
|
359
|
+
matches.push(this.contactSummary(jid, contact));
|
|
360
|
+
if (matches.length >= limit)
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
return matches;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
getContact(contactId) {
|
|
367
|
+
return this.guarded(async () => {
|
|
368
|
+
const sock = this.ensureConnected();
|
|
369
|
+
const jid = this.resolveId(contactId);
|
|
370
|
+
const contact = this.store.contacts.get(jid);
|
|
371
|
+
const [about, picture] = await Promise.all([
|
|
372
|
+
sock
|
|
373
|
+
.fetchStatus(jid)
|
|
374
|
+
.then((entries) => statusTextOf(entries?.[0]))
|
|
375
|
+
.catch(() => null),
|
|
376
|
+
sock.profilePictureUrl(jid, "image").catch(() => null),
|
|
377
|
+
]);
|
|
378
|
+
return {
|
|
379
|
+
...this.contactSummary(jid, contact),
|
|
380
|
+
about,
|
|
381
|
+
profile_pic_url: picture ?? null,
|
|
382
|
+
is_blocked: this.blocked.has(jid),
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
getGroupInfo(groupId) {
|
|
387
|
+
return this.guarded(async () => {
|
|
388
|
+
this.ensureConnected();
|
|
389
|
+
const jid = this.resolveId(groupId);
|
|
390
|
+
if (!isGroupId(jid)) {
|
|
391
|
+
throw new WazapError("GROUP_NOT_FOUND", `"${groupId}" is not a group id.`, "Group ids end in @g.us");
|
|
392
|
+
}
|
|
393
|
+
const meta = await this.groupMeta(jid, true);
|
|
394
|
+
const mine = this.myParticipation(meta);
|
|
395
|
+
if (!mine) {
|
|
396
|
+
throw new WazapError("NOT_A_PARTICIPANT", `The linked account is not a participant of ${jid}.`);
|
|
397
|
+
}
|
|
398
|
+
const iAmAdmin = isAdmin(mine);
|
|
399
|
+
const info = {
|
|
400
|
+
chat_id: jid,
|
|
401
|
+
name: meta.subject,
|
|
402
|
+
description: meta.desc ?? null,
|
|
403
|
+
owner: meta.owner ? this.canonical(meta.owner) : null,
|
|
404
|
+
created_at: meta.creation ? isoWithOffset(meta.creation * 1000) : null,
|
|
405
|
+
participant_count: meta.participants.length,
|
|
406
|
+
participants: meta.participants.slice(0, MAX_GROUP_PARTICIPANTS).map((p) => {
|
|
407
|
+
const id = this.canonical(p.id);
|
|
408
|
+
return { contact_id: id, name: this.nameFor(id), is_admin: isAdmin(p) };
|
|
409
|
+
}),
|
|
410
|
+
announcement_only: Boolean(meta.announce),
|
|
411
|
+
i_am_admin: iAmAdmin,
|
|
412
|
+
};
|
|
413
|
+
if (iAmAdmin) {
|
|
414
|
+
const link = await this.inviteLink(jid).catch(() => null);
|
|
415
|
+
if (link)
|
|
416
|
+
info.invite_link = link;
|
|
417
|
+
}
|
|
418
|
+
return info;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
downloadMedia(messageId, saveTo) {
|
|
422
|
+
return this.guarded(async () => {
|
|
423
|
+
const sock = this.ensureConnected();
|
|
424
|
+
const raw = this.messageOrThrow(messageId);
|
|
425
|
+
const info = mediaInfo(raw);
|
|
426
|
+
if (!info)
|
|
427
|
+
throw new WazapError("MEDIA_UNAVAILABLE", `Message ${messageId} carries no media.`);
|
|
428
|
+
let buffer;
|
|
429
|
+
try {
|
|
430
|
+
buffer = await downloadMediaMessage(raw, "buffer", {}, {
|
|
431
|
+
logger: silentLogger,
|
|
432
|
+
reuploadRequest: sock.updateMediaMessage,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
throw new WazapError("MEDIA_UNAVAILABLE", `Could not download the media of ${messageId}: ${describe(err)}`, "Ask the sender to resend it");
|
|
437
|
+
}
|
|
438
|
+
const dir = saveTo ?? this.paths.mediaDir;
|
|
439
|
+
if (!isAbsolute(dir)) {
|
|
440
|
+
throw new WazapError("FILE_NOT_FOUND", `"${dir}" is not an absolute directory path.`);
|
|
441
|
+
}
|
|
442
|
+
await mkdir(dir, { recursive: true, mode: DIR_MODE });
|
|
443
|
+
const filename = mediaFilename(info);
|
|
444
|
+
const path = join(dir, filename);
|
|
445
|
+
await writeFile(path, buffer, { mode: FILE_MODE });
|
|
446
|
+
const inline = info.mime.startsWith("image/") && buffer.length <= INLINE_IMAGE_MAX_BYTES ? buffer.toString("base64") : null;
|
|
447
|
+
return { path, mime: info.mime, size: buffer.length, filename, inline_base64: inline };
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
sendMessage(chatId, text, replyTo, mentionIds) {
|
|
451
|
+
return this.guarded(async () => {
|
|
452
|
+
this.beginWrite();
|
|
453
|
+
if (text.length > MAX_TEXT_CHARS) {
|
|
454
|
+
throw new WazapError("TEXT_TOO_LONG", `The text is ${text.length} characters; WhatsApp allows ${MAX_TEXT_CHARS}.`);
|
|
455
|
+
}
|
|
456
|
+
const { sock, jid } = await this.prepareSend(chatId);
|
|
457
|
+
const mentions = (mentionIds ?? []).map((id) => this.resolveId(id));
|
|
458
|
+
const quoted = replyTo === undefined ? undefined : this.messageOrThrow(replyTo);
|
|
459
|
+
const sent = await sock.sendMessage(jid, mentions.length > 0 ? { text, mentions } : { text }, quoted ? { quoted } : {});
|
|
460
|
+
return this.sentResult(sent, jid, text);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
sendMedia(chatId, source, opts) {
|
|
464
|
+
return this.guarded(async () => {
|
|
465
|
+
const { sock, jid } = await this.prepareSend(chatId);
|
|
466
|
+
const media = await loadMedia(source);
|
|
467
|
+
const content = mediaContent(media, opts);
|
|
468
|
+
const sent = await sock.sendMessage(jid, content);
|
|
469
|
+
return this.sentResult(sent, jid, opts.caption ?? `[${media.mimetype}]`);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
sendPoll(chatId, question, options, multiSelect) {
|
|
473
|
+
return this.guarded(async () => {
|
|
474
|
+
const { sock, jid } = await this.prepareSend(chatId);
|
|
475
|
+
const sent = await sock.sendMessage(jid, {
|
|
476
|
+
poll: { name: question, values: options, selectableCount: multiSelect ? options.length : 1 },
|
|
477
|
+
});
|
|
478
|
+
return this.sentResult(sent, jid, `[poll] ${question}`);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
sendLocation(chatId, latitude, longitude, name, address) {
|
|
482
|
+
return this.guarded(async () => {
|
|
483
|
+
const { sock, jid } = await this.prepareSend(chatId);
|
|
484
|
+
const sent = await sock.sendMessage(jid, {
|
|
485
|
+
location: { degreesLatitude: latitude, degreesLongitude: longitude, name, address },
|
|
486
|
+
});
|
|
487
|
+
return this.sentResult(sent, jid, `[location] ${name ?? `${latitude}, ${longitude}`}`);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
editMessage(messageId, text) {
|
|
491
|
+
return this.guarded(async () => {
|
|
492
|
+
this.beginWrite();
|
|
493
|
+
if (text.length > MAX_TEXT_CHARS) {
|
|
494
|
+
throw new WazapError("TEXT_TOO_LONG", `The text is ${text.length} characters; WhatsApp allows ${MAX_TEXT_CHARS}.`);
|
|
495
|
+
}
|
|
496
|
+
const raw = this.messageOrThrow(messageId);
|
|
497
|
+
if (!raw.key.fromMe) {
|
|
498
|
+
throw new WazapError("NOT_OWN_MESSAGE", `Message ${messageId} was not sent by the linked account.`);
|
|
499
|
+
}
|
|
500
|
+
const age = Date.now() - messageTimestampMs(raw);
|
|
501
|
+
if (age > EDIT_WINDOW_MS) {
|
|
502
|
+
throw new WazapError("EDIT_WINDOW_EXPIRED", `Message ${messageId} is older than 15 minutes.`);
|
|
503
|
+
}
|
|
504
|
+
const { sock, jid } = await this.prepareSend(this.chatOfOrThrow(messageId));
|
|
505
|
+
await sock.sendMessage(jid, { text, edit: raw.key });
|
|
506
|
+
return { message_id: messageId, chat_id: jid, text, timestamp: isoWithOffset(Date.now()) };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
reactToMessage(messageId, emoji) {
|
|
510
|
+
return this.guarded(async () => {
|
|
511
|
+
this.beginWrite();
|
|
512
|
+
const raw = this.messageOrThrow(messageId);
|
|
513
|
+
const { sock, jid } = await this.prepareSend(this.chatOfOrThrow(messageId));
|
|
514
|
+
await sock.sendMessage(jid, { react: { text: emoji, key: raw.key } });
|
|
515
|
+
return { message_id: messageId, emoji };
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
forwardMessage(messageId, toChatId) {
|
|
519
|
+
return this.guarded(async () => {
|
|
520
|
+
this.beginWrite();
|
|
521
|
+
const raw = this.messageOrThrow(messageId);
|
|
522
|
+
const { sock, jid } = await this.prepareSend(toChatId);
|
|
523
|
+
const sent = await sock.sendMessage(jid, { forward: raw });
|
|
524
|
+
return this.sentResult(sent, jid, messageText(raw));
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
deleteMessage(messageId, forEveryone) {
|
|
528
|
+
return this.guarded(async () => {
|
|
529
|
+
this.beginWrite();
|
|
530
|
+
const raw = this.messageOrThrow(messageId);
|
|
531
|
+
if (!forEveryone) {
|
|
532
|
+
throw new WazapError("WHATSAPP_ERROR", "WhatsApp only supports delete-for-everyone from a linked device; deleting for yourself alone is not available.", "Call delete_message again with for_everyone=true");
|
|
533
|
+
}
|
|
534
|
+
if (!raw.key.fromMe) {
|
|
535
|
+
throw new WazapError("NOT_OWN_MESSAGE", `Message ${messageId} was not sent by the linked account.`);
|
|
536
|
+
}
|
|
537
|
+
if (Date.now() - messageTimestampMs(raw) > RETRACT_WINDOW_MS) {
|
|
538
|
+
throw new WazapError("RETRACT_WINDOW_EXPIRED", `Message ${messageId} is older than 2 days.`);
|
|
539
|
+
}
|
|
540
|
+
const { sock, jid } = await this.prepareSend(this.chatOfOrThrow(messageId));
|
|
541
|
+
await sock.sendMessage(jid, { delete: raw.key });
|
|
542
|
+
return { message_id: messageId, for_everyone: true };
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
manageChat(chatId, action, muteHours) {
|
|
546
|
+
return this.guarded(async () => {
|
|
547
|
+
const sock = this.beginWrite();
|
|
548
|
+
const jid = this.resolveId(chatId);
|
|
549
|
+
const last = this.lastMessageOf(jid);
|
|
550
|
+
const lastMessages = last ? [last] : [];
|
|
551
|
+
switch (action) {
|
|
552
|
+
case "archive":
|
|
553
|
+
case "unarchive":
|
|
554
|
+
await sock.chatModify({ archive: action === "archive", lastMessages }, jid);
|
|
555
|
+
break;
|
|
556
|
+
case "pin":
|
|
557
|
+
case "unpin":
|
|
558
|
+
await sock.chatModify({ pin: action === "pin" }, jid);
|
|
559
|
+
break;
|
|
560
|
+
case "mute":
|
|
561
|
+
await sock.chatModify({ mute: (muteHours ?? 8) * 3_600_000 }, jid);
|
|
562
|
+
break;
|
|
563
|
+
case "unmute":
|
|
564
|
+
await sock.chatModify({ mute: null }, jid);
|
|
565
|
+
break;
|
|
566
|
+
case "mark_read":
|
|
567
|
+
if (last)
|
|
568
|
+
await sock.readMessages([last.key]);
|
|
569
|
+
break;
|
|
570
|
+
case "mark_unread":
|
|
571
|
+
await sock.chatModify({ markRead: false, lastMessages }, jid);
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
const detail = action === "mute" ? ` for ${muteHours ?? 8}h` : "";
|
|
575
|
+
return { chat_id: jid, action, applied: `${action}${detail}` };
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
createGroup(name, participantIds) {
|
|
579
|
+
return this.guarded(async () => {
|
|
580
|
+
const sock = this.beginWrite();
|
|
581
|
+
const ids = participantIds.map((id) => this.resolveId(id));
|
|
582
|
+
const meta = await sock.groupCreate(name, ids);
|
|
583
|
+
this.groupCache.set(this.canonical(meta.id), meta);
|
|
584
|
+
const present = new Set(meta.participants.map((p) => this.canonical(p.id)));
|
|
585
|
+
return {
|
|
586
|
+
chat_id: this.canonical(meta.id),
|
|
587
|
+
participants: ids.map((id) => present.has(id)
|
|
588
|
+
? { id, status: "ok" }
|
|
589
|
+
: { id, status: "failed", reason: "WhatsApp did not add this participant" }),
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
manageGroup(groupId, action, participantIds, value) {
|
|
594
|
+
return this.guarded(async () => {
|
|
595
|
+
const sock = this.beginWrite();
|
|
596
|
+
const jid = this.resolveId(groupId);
|
|
597
|
+
if (!isGroupId(jid)) {
|
|
598
|
+
throw new WazapError("GROUP_NOT_FOUND", `"${groupId}" is not a group id.`, "Group ids end in @g.us");
|
|
599
|
+
}
|
|
600
|
+
if (ADMIN_ACTIONS.has(action))
|
|
601
|
+
await this.assertGroupAdmin(jid, action);
|
|
602
|
+
const ids = (participantIds ?? []).map((id) => this.resolveId(id));
|
|
603
|
+
if (PARTICIPANT_ACTIONS.has(action) && ids.length === 0) {
|
|
604
|
+
throw new WazapError("INVALID_ID", `The "${action}" action needs at least one participant id.`);
|
|
605
|
+
}
|
|
606
|
+
switch (action) {
|
|
607
|
+
case "add":
|
|
608
|
+
case "remove":
|
|
609
|
+
case "promote":
|
|
610
|
+
case "demote": {
|
|
611
|
+
const results = await sock.groupParticipantsUpdate(jid, ids, action);
|
|
612
|
+
this.groupCache.delete(jid);
|
|
613
|
+
return {
|
|
614
|
+
group_id: jid,
|
|
615
|
+
action,
|
|
616
|
+
applied: `${action} ${ids.length} participant(s)`,
|
|
617
|
+
participants: results.map((entry, index) => this.participantResult(entry, ids[index])),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
case "leave":
|
|
621
|
+
await sock.groupLeave(jid);
|
|
622
|
+
this.groupCache.delete(jid);
|
|
623
|
+
return { group_id: jid, action, applied: "left the group" };
|
|
624
|
+
case "set_subject": {
|
|
625
|
+
const subject = requireValue(value, "set_subject", "the new group name");
|
|
626
|
+
await sock.groupUpdateSubject(jid, subject);
|
|
627
|
+
this.groupCache.delete(jid);
|
|
628
|
+
return { group_id: jid, action, applied: `subject set to "${subject}"` };
|
|
629
|
+
}
|
|
630
|
+
case "set_description": {
|
|
631
|
+
const description = requireValue(value, "set_description", "the new description");
|
|
632
|
+
await sock.groupUpdateDescription(jid, description);
|
|
633
|
+
this.groupCache.delete(jid);
|
|
634
|
+
return { group_id: jid, action, applied: "description updated" };
|
|
635
|
+
}
|
|
636
|
+
case "get_invite_link": {
|
|
637
|
+
const link = await this.inviteLink(jid);
|
|
638
|
+
return { group_id: jid, action, applied: "invite link fetched", invite_link: link };
|
|
639
|
+
}
|
|
640
|
+
case "revoke_invite_link": {
|
|
641
|
+
const code = await sock.groupRevokeInvite(jid);
|
|
642
|
+
const link = code ? `https://chat.whatsapp.com/${code}` : undefined;
|
|
643
|
+
return {
|
|
644
|
+
group_id: jid,
|
|
645
|
+
action,
|
|
646
|
+
applied: "invite link revoked",
|
|
647
|
+
...(link ? { invite_link: link } : {}),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/** Close the current socket and mute it, so a socket we are replacing can no
|
|
654
|
+
* longer emit a close event and trigger a reconnect of its own. */
|
|
655
|
+
teardownSocket() {
|
|
656
|
+
const sock = this.sockClient;
|
|
657
|
+
if (!sock)
|
|
658
|
+
return;
|
|
659
|
+
this.sockClient = null;
|
|
660
|
+
try {
|
|
661
|
+
sock.ev.removeAllListeners("connection.update");
|
|
662
|
+
void sock.end(undefined);
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
logError("teardown", err);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
scheduleReconnect(reason) {
|
|
669
|
+
if (this.stopped || this.reconnectTimer)
|
|
670
|
+
return;
|
|
671
|
+
this.teardownSocket();
|
|
672
|
+
if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
673
|
+
this.status = "auth_failure";
|
|
674
|
+
this.lastError =
|
|
675
|
+
`${reason} — gave up after ${RECONNECT_MAX_ATTEMPTS} attempts. ` +
|
|
676
|
+
"WhatsApp keeps rejecting this session: re-link the device with `npx wazap-mcp login`.";
|
|
677
|
+
logError("reconnect", this.lastError);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const attempt = this.reconnectAttempts++;
|
|
681
|
+
const backoff = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
|
682
|
+
const delay = Math.round(backoff * (0.5 + Math.random()));
|
|
683
|
+
log(`disconnected (${reason}); retry ${attempt + 1}/${RECONNECT_MAX_ATTEMPTS} in ${Math.round(delay / 1000)}s`);
|
|
684
|
+
this.reconnectTimer = setTimeout(() => {
|
|
685
|
+
this.reconnectTimer = null;
|
|
686
|
+
this.start().catch((err) => {
|
|
687
|
+
logError("reconnect", err);
|
|
688
|
+
this.scheduleReconnect("reconnect failed");
|
|
689
|
+
});
|
|
690
|
+
}, delay);
|
|
691
|
+
}
|
|
692
|
+
wireEvents(sock, generation) {
|
|
693
|
+
sock.ev.on("creds.update", () => void this.saveCreds?.());
|
|
694
|
+
sock.ev.on("connection.update", (update) => {
|
|
695
|
+
if (generation !== this.generation)
|
|
696
|
+
return;
|
|
697
|
+
const { connection, lastDisconnect } = update;
|
|
698
|
+
if (connection === "open") {
|
|
699
|
+
this.reconnectAttempts = 0;
|
|
700
|
+
this.status = "connected";
|
|
701
|
+
this.lastError = null;
|
|
702
|
+
this.adoptSocketAccount();
|
|
703
|
+
this.armSyncDeadline();
|
|
704
|
+
log("connected to WhatsApp");
|
|
705
|
+
}
|
|
706
|
+
else if (connection === "close") {
|
|
707
|
+
const code = statusCodeOf(lastDisconnect?.error);
|
|
708
|
+
if (code === DisconnectReason.loggedOut) {
|
|
709
|
+
this.status = "logged_out";
|
|
710
|
+
this.lastError = "The account was unlinked from the phone.";
|
|
711
|
+
logError("auth", this.lastError);
|
|
712
|
+
this.teardownSocket();
|
|
713
|
+
}
|
|
714
|
+
else if (!this.stopped) {
|
|
715
|
+
this.status = "disconnected";
|
|
716
|
+
this.lastError = lastDisconnect?.error?.message ?? "connection closed";
|
|
717
|
+
this.scheduleReconnect(this.lastError);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
sock.ev.on("messaging-history.set", ({ chats, contacts, messages, lidPnMappings, isLatest, progress }) => {
|
|
722
|
+
for (const mapping of lidPnMappings ?? [])
|
|
723
|
+
this.learnLid(mapping.lid, mapping.pn);
|
|
724
|
+
for (const contact of contacts)
|
|
725
|
+
this.ingestContact(contact);
|
|
726
|
+
for (const chat of chats)
|
|
727
|
+
this.ingestChat(chat);
|
|
728
|
+
const stored = this.ingestMessages(messages ?? []);
|
|
729
|
+
void this.appendHistory(stored);
|
|
730
|
+
this.releaseHistoryWaiters();
|
|
731
|
+
if (isLatest === true || progress === 100)
|
|
732
|
+
this.markSyncDone();
|
|
733
|
+
this.markStoreDirty();
|
|
734
|
+
});
|
|
735
|
+
sock.ev.on("lid-mapping.update", (mapping) => this.learnLid(mapping.lid, mapping.pn));
|
|
736
|
+
sock.ev.on("chats.upsert", (chats) => {
|
|
737
|
+
for (const chat of chats)
|
|
738
|
+
this.ingestChat(chat);
|
|
739
|
+
this.markStoreDirty();
|
|
740
|
+
});
|
|
741
|
+
sock.ev.on("chats.update", (updates) => {
|
|
742
|
+
for (const update of updates) {
|
|
743
|
+
if (!update.id)
|
|
744
|
+
continue;
|
|
745
|
+
const jid = this.canonical(update.id);
|
|
746
|
+
const previous = this.store.chats.get(jid);
|
|
747
|
+
this.store.chats.set(jid, { ...(previous ?? {}), ...update, id: jid });
|
|
748
|
+
}
|
|
749
|
+
this.markStoreDirty();
|
|
750
|
+
});
|
|
751
|
+
sock.ev.on("chats.delete", (ids) => {
|
|
752
|
+
for (const id of ids)
|
|
753
|
+
this.store.chats.delete(this.canonical(id));
|
|
754
|
+
});
|
|
755
|
+
sock.ev.on("contacts.upsert", (contacts) => {
|
|
756
|
+
for (const contact of contacts)
|
|
757
|
+
this.ingestContact(contact);
|
|
758
|
+
this.markStoreDirty();
|
|
759
|
+
});
|
|
760
|
+
sock.ev.on("contacts.update", (updates) => {
|
|
761
|
+
for (const update of updates) {
|
|
762
|
+
if (!update.id)
|
|
763
|
+
continue;
|
|
764
|
+
const previous = this.store.contacts.get(this.canonical(update.id));
|
|
765
|
+
this.ingestContact({ ...(previous ?? {}), ...update, id: update.id });
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
sock.ev.on("messages.upsert", ({ messages, type }) => {
|
|
769
|
+
const stored = this.ingestMessages(messages);
|
|
770
|
+
if (type === "notify") {
|
|
771
|
+
for (const raw of messages) {
|
|
772
|
+
if (raw.key.fromMe)
|
|
773
|
+
continue;
|
|
774
|
+
this.lastInboundAt = Math.max(this.lastInboundAt ?? 0, messageTimestampMs(raw));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
void this.appendHistory(stored);
|
|
778
|
+
this.markStoreDirty();
|
|
779
|
+
});
|
|
780
|
+
sock.ev.on("messages.update", (updates) => {
|
|
781
|
+
for (const { key, update } of updates) {
|
|
782
|
+
const jid = key.remoteJid ? this.canonical(key.remoteJid) : undefined;
|
|
783
|
+
if (!jid)
|
|
784
|
+
continue;
|
|
785
|
+
const sid = messageIdFor(key, jid);
|
|
786
|
+
const raw = this.store.messages.get(sid);
|
|
787
|
+
if (!raw)
|
|
788
|
+
continue;
|
|
789
|
+
const edited = update.message?.editedMessage?.message;
|
|
790
|
+
if (edited) {
|
|
791
|
+
this.store.edited.add(sid);
|
|
792
|
+
raw.message = edited;
|
|
793
|
+
}
|
|
794
|
+
if (update.messageTimestamp)
|
|
795
|
+
raw.messageTimestamp = update.messageTimestamp;
|
|
796
|
+
this.markStoreDirty();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
sock.ev.on("messages.reaction", (items) => {
|
|
800
|
+
for (const { key, reaction } of items) {
|
|
801
|
+
const jid = key.remoteJid ? this.canonical(key.remoteJid) : undefined;
|
|
802
|
+
if (!jid)
|
|
803
|
+
continue;
|
|
804
|
+
const target = messageIdFor(key, jid);
|
|
805
|
+
const author = reaction.key?.fromMe
|
|
806
|
+
? this.ownJid()
|
|
807
|
+
: this.canonical(reaction.key?.participant ?? reaction.key?.remoteJid ?? "");
|
|
808
|
+
if (!author)
|
|
809
|
+
continue;
|
|
810
|
+
const map = this.store.reactions.get(target) ?? new Map();
|
|
811
|
+
if (reaction.text)
|
|
812
|
+
map.set(author, reaction.text);
|
|
813
|
+
else
|
|
814
|
+
map.delete(author);
|
|
815
|
+
if (map.size > 0)
|
|
816
|
+
this.store.reactions.set(target, map);
|
|
817
|
+
else
|
|
818
|
+
this.store.reactions.delete(target);
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
sock.ev.on("groups.upsert", (groups) => {
|
|
822
|
+
for (const meta of groups)
|
|
823
|
+
this.groupCache.set(this.canonical(meta.id), meta);
|
|
824
|
+
});
|
|
825
|
+
sock.ev.on("groups.update", (updates) => {
|
|
826
|
+
for (const update of updates) {
|
|
827
|
+
if (!update.id)
|
|
828
|
+
continue;
|
|
829
|
+
const jid = this.canonical(update.id);
|
|
830
|
+
const previous = this.groupCache.get(jid);
|
|
831
|
+
if (previous)
|
|
832
|
+
this.groupCache.set(jid, { ...previous, ...update });
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
sock.ev.on("group-participants.update", ({ id }) => this.groupCache.delete(this.canonical(id)));
|
|
836
|
+
sock.ev.on("blocklist.set", ({ blocklist }) => {
|
|
837
|
+
this.blocked.clear();
|
|
838
|
+
for (const jid of blocklist)
|
|
839
|
+
this.blocked.add(this.canonical(jid));
|
|
840
|
+
});
|
|
841
|
+
sock.ev.on("blocklist.update", ({ blocklist, type }) => {
|
|
842
|
+
for (const jid of blocklist) {
|
|
843
|
+
if (type === "add")
|
|
844
|
+
this.blocked.add(this.canonical(jid));
|
|
845
|
+
else
|
|
846
|
+
this.blocked.delete(this.canonical(jid));
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
readAccount() {
|
|
851
|
+
let linked;
|
|
852
|
+
try {
|
|
853
|
+
linked = readLinkedAccount(this.paths.authDir);
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
this.markCorrupt(err);
|
|
857
|
+
return "corrupt";
|
|
858
|
+
}
|
|
859
|
+
if (!linked) {
|
|
860
|
+
this.status = "not_linked";
|
|
861
|
+
this.account = null;
|
|
862
|
+
this.lastError = null;
|
|
863
|
+
log("no WhatsApp account is linked; run `npx wazap-mcp login`");
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
return { id: linked.id, name: linked.name, number: linked.number };
|
|
867
|
+
}
|
|
868
|
+
markCorrupt(err) {
|
|
869
|
+
this.status = "session_corrupt";
|
|
870
|
+
this.lastError = describe(err);
|
|
871
|
+
logError("auth state", err);
|
|
872
|
+
}
|
|
873
|
+
adoptSocketAccount() {
|
|
874
|
+
const user = this.sockClient?.user;
|
|
875
|
+
if (!user?.id)
|
|
876
|
+
return;
|
|
877
|
+
const id = this.canonical(user.id);
|
|
878
|
+
this.account = { id, name: user.name ?? this.account?.name ?? "", number: id.split("@")[0] ?? "" };
|
|
879
|
+
if (user.lid)
|
|
880
|
+
this.learnLid(user.lid, id);
|
|
881
|
+
}
|
|
882
|
+
ownJid() {
|
|
883
|
+
const id = this.sockClient?.user?.id;
|
|
884
|
+
if (id)
|
|
885
|
+
return this.canonical(id);
|
|
886
|
+
return this.account?.id ?? "";
|
|
887
|
+
}
|
|
888
|
+
isMe(jid) {
|
|
889
|
+
const own = this.ownJid();
|
|
890
|
+
if (!own)
|
|
891
|
+
return false;
|
|
892
|
+
if (this.canonical(jid) === own)
|
|
893
|
+
return true;
|
|
894
|
+
const lid = this.sockClient?.user?.lid;
|
|
895
|
+
return lid !== undefined && jidNormalizedUser(lid) === jidNormalizedUser(jid);
|
|
896
|
+
}
|
|
897
|
+
armSyncDeadline() {
|
|
898
|
+
if (this.syncDeadline)
|
|
899
|
+
clearTimeout(this.syncDeadline);
|
|
900
|
+
this.syncDeadline = setTimeout(() => this.markSyncDone(), SYNC_WAIT_MS);
|
|
901
|
+
}
|
|
902
|
+
markSyncDone() {
|
|
903
|
+
if (this.syncDeadline) {
|
|
904
|
+
clearTimeout(this.syncDeadline);
|
|
905
|
+
this.syncDeadline = null;
|
|
906
|
+
}
|
|
907
|
+
if (this.initialSyncDone)
|
|
908
|
+
return;
|
|
909
|
+
this.initialSyncDone = true;
|
|
910
|
+
this.releaseWaiters();
|
|
911
|
+
}
|
|
912
|
+
releaseWaiters() {
|
|
913
|
+
const waiters = this.syncWaiters;
|
|
914
|
+
this.syncWaiters = [];
|
|
915
|
+
for (const waiter of waiters)
|
|
916
|
+
waiter();
|
|
917
|
+
this.releaseHistoryWaiters();
|
|
918
|
+
}
|
|
919
|
+
releaseHistoryWaiters() {
|
|
920
|
+
const waiters = this.historyWaiters;
|
|
921
|
+
this.historyWaiters = [];
|
|
922
|
+
for (const waiter of waiters)
|
|
923
|
+
waiter();
|
|
924
|
+
}
|
|
925
|
+
/** Resolves as soon as the initial sync lands, and in any case within 10s. */
|
|
926
|
+
waitForSync() {
|
|
927
|
+
if (this.initialSyncDone)
|
|
928
|
+
return Promise.resolve();
|
|
929
|
+
return new Promise((done) => {
|
|
930
|
+
const timer = setTimeout(() => {
|
|
931
|
+
this.syncWaiters = this.syncWaiters.filter((entry) => entry !== waiter);
|
|
932
|
+
done();
|
|
933
|
+
}, SYNC_WAIT_MS);
|
|
934
|
+
const waiter = () => {
|
|
935
|
+
clearTimeout(timer);
|
|
936
|
+
done();
|
|
937
|
+
};
|
|
938
|
+
this.syncWaiters.push(waiter);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
syncState() {
|
|
942
|
+
return this.initialSyncDone ? "done" : "in_progress";
|
|
943
|
+
}
|
|
944
|
+
synced(data) {
|
|
945
|
+
return { data, sync: this.syncState() };
|
|
946
|
+
}
|
|
947
|
+
/** Every public method funnels through here, so no raw Baileys error escapes. */
|
|
948
|
+
async guarded(work) {
|
|
949
|
+
try {
|
|
950
|
+
return await work();
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
throw asWazapError(err);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
ensureConnected() {
|
|
957
|
+
switch (this.status) {
|
|
958
|
+
case "not_linked":
|
|
959
|
+
throw new WazapError("NOT_LINKED", "No WhatsApp account is linked.", RELINK_FIX);
|
|
960
|
+
case "session_corrupt":
|
|
961
|
+
throw new WazapError("SESSION_CORRUPT", this.lastError ?? "Stored credentials are unreadable.", RESET_FIX);
|
|
962
|
+
case "logged_out":
|
|
963
|
+
throw new WazapError("SESSION_EXPIRED", this.lastError ?? "The account was unlinked.", RELINK_FIX);
|
|
964
|
+
case "auth_failure":
|
|
965
|
+
throw new WazapError("NOT_CONNECTED", this.lastError ?? "WhatsApp refused this session.");
|
|
966
|
+
case "connecting":
|
|
967
|
+
case "disconnected":
|
|
968
|
+
throw new WazapError("NOT_CONNECTED", `The WhatsApp socket is ${this.status}.`, "Call get_status, wait, retry");
|
|
969
|
+
case "connected": {
|
|
970
|
+
if (!this.sockClient)
|
|
971
|
+
throw new WazapError("NOT_CONNECTED", "The WhatsApp socket is gone.");
|
|
972
|
+
return this.sockClient;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/** First statement of every write, so a broken link is reported before anything else. */
|
|
977
|
+
beginWrite() {
|
|
978
|
+
if (this.config.readOnly) {
|
|
979
|
+
throw new WazapError("READ_ONLY", "wazap runs read-only, so this write is refused.");
|
|
980
|
+
}
|
|
981
|
+
return this.ensureConnected();
|
|
982
|
+
}
|
|
983
|
+
/** The single gate every send path passes: writability, addressability, announce-only. */
|
|
984
|
+
async prepareSend(chatId) {
|
|
985
|
+
const sock = this.beginWrite();
|
|
986
|
+
const jid = this.resolveId(chatId);
|
|
987
|
+
if (isGroupId(jid)) {
|
|
988
|
+
const meta = await this.groupMeta(jid);
|
|
989
|
+
const mine = this.myParticipation(meta);
|
|
990
|
+
if (meta.announce && !(mine && isAdmin(mine))) {
|
|
991
|
+
throw new WazapError("GROUP_ANNOUNCEMENT_ONLY", `Only admins may post in "${meta.subject}".`);
|
|
992
|
+
}
|
|
993
|
+
return { sock, jid };
|
|
994
|
+
}
|
|
995
|
+
if (!this.store.chats.has(jid) && !this.store.contacts.has(jid)) {
|
|
996
|
+
const found = await sock.onWhatsApp(jid).catch(() => undefined);
|
|
997
|
+
if (!found?.some((entry) => entry.exists)) {
|
|
998
|
+
throw new WazapError("NOT_ON_WHATSAPP", `${jid} has no WhatsApp account.`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return { sock, jid };
|
|
1002
|
+
}
|
|
1003
|
+
async groupMeta(jid, fresh = false) {
|
|
1004
|
+
const cached = this.groupCache.get(jid);
|
|
1005
|
+
if (cached && !fresh)
|
|
1006
|
+
return cached;
|
|
1007
|
+
const sock = this.ensureConnected();
|
|
1008
|
+
let meta;
|
|
1009
|
+
try {
|
|
1010
|
+
meta = await sock.groupMetadata(jid);
|
|
1011
|
+
}
|
|
1012
|
+
catch (err) {
|
|
1013
|
+
const code = statusCodeOf(err);
|
|
1014
|
+
if (code === 403)
|
|
1015
|
+
throw new WazapError("NOT_A_PARTICIPANT", `The linked account is not in ${jid}.`);
|
|
1016
|
+
if (code === 404)
|
|
1017
|
+
throw new WazapError("GROUP_NOT_FOUND", `WhatsApp does not know the group ${jid}.`);
|
|
1018
|
+
throw new WazapError("GROUP_NOT_FOUND", `Could not read ${jid}: ${describe(err)}`);
|
|
1019
|
+
}
|
|
1020
|
+
this.groupCache.set(jid, meta);
|
|
1021
|
+
return meta;
|
|
1022
|
+
}
|
|
1023
|
+
myParticipation(meta) {
|
|
1024
|
+
return meta.participants.find((p) => this.isMe(p.id) || (p.phoneNumber && this.isMe(p.phoneNumber)));
|
|
1025
|
+
}
|
|
1026
|
+
async assertGroupAdmin(jid, action) {
|
|
1027
|
+
const meta = await this.groupMeta(jid);
|
|
1028
|
+
const mine = this.myParticipation(meta);
|
|
1029
|
+
if (!mine)
|
|
1030
|
+
throw new WazapError("NOT_A_PARTICIPANT", `The linked account is not in ${jid}.`);
|
|
1031
|
+
if (!isAdmin(mine)) {
|
|
1032
|
+
throw new WazapError("NOT_ADMIN", `"${action}" needs admin rights in "${meta.subject}".`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async inviteLink(jid) {
|
|
1036
|
+
const sock = this.ensureConnected();
|
|
1037
|
+
const code = await sock.groupInviteCode(jid);
|
|
1038
|
+
if (!code)
|
|
1039
|
+
throw new WazapError("WHATSAPP_ERROR", `WhatsApp returned no invite code for ${jid}.`);
|
|
1040
|
+
return `https://chat.whatsapp.com/${code}`;
|
|
1041
|
+
}
|
|
1042
|
+
participantResult(entry, fallback) {
|
|
1043
|
+
const id = entry.jid ? this.canonical(entry.jid) : (fallback ?? "");
|
|
1044
|
+
if (entry.status === "200")
|
|
1045
|
+
return { id, status: "ok" };
|
|
1046
|
+
if (INVITE_NEEDED_CODES.has(entry.status)) {
|
|
1047
|
+
return { id, status: "invite_needed", reason: entry.status };
|
|
1048
|
+
}
|
|
1049
|
+
return { id, status: "failed", reason: entry.status };
|
|
1050
|
+
}
|
|
1051
|
+
resolveId(input) {
|
|
1052
|
+
return resolveChatId(input, (lid) => this.lidToPn.get(lid));
|
|
1053
|
+
}
|
|
1054
|
+
/** Canonical form, or the input unchanged for jids wazap does not address
|
|
1055
|
+
* (status broadcasts, newsletters). */
|
|
1056
|
+
canonical(jid) {
|
|
1057
|
+
if (!jid)
|
|
1058
|
+
return "";
|
|
1059
|
+
try {
|
|
1060
|
+
return resolveChatId(jid, (lid) => this.lidToPn.get(lid));
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
return jid;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
learnLid(lid, pn) {
|
|
1067
|
+
if (!lid || !pn)
|
|
1068
|
+
return;
|
|
1069
|
+
const key = `${jidNormalizedUser(lid).split("@")[0]}@lid`;
|
|
1070
|
+
this.lidToPn.set(key, pn);
|
|
1071
|
+
}
|
|
1072
|
+
nameFor(jid) {
|
|
1073
|
+
if (this.isMe(jid))
|
|
1074
|
+
return this.account?.name || "You";
|
|
1075
|
+
const chat = this.store.chats.get(jid);
|
|
1076
|
+
if (chat?.name)
|
|
1077
|
+
return chat.name;
|
|
1078
|
+
const contact = this.store.contacts.get(jid);
|
|
1079
|
+
if (contact?.name || contact?.notify)
|
|
1080
|
+
return contact.name || contact.notify || "";
|
|
1081
|
+
if (isGroupId(jid))
|
|
1082
|
+
return this.groupCache.get(jid)?.subject ?? jid;
|
|
1083
|
+
return jid.split("@")[0] ?? jid;
|
|
1084
|
+
}
|
|
1085
|
+
messageOrThrow(messageId) {
|
|
1086
|
+
const raw = this.store.messages.get(messageId);
|
|
1087
|
+
if (!raw) {
|
|
1088
|
+
throw new WazapError("MESSAGE_NOT_FOUND", `No message "${messageId}" is loaded.`, "Use a message_id from read_messages or search_messages");
|
|
1089
|
+
}
|
|
1090
|
+
return raw;
|
|
1091
|
+
}
|
|
1092
|
+
chatOfOrThrow(messageId) {
|
|
1093
|
+
const jid = this.store.chatOf.get(messageId);
|
|
1094
|
+
if (!jid)
|
|
1095
|
+
throw new WazapError("MESSAGE_NOT_FOUND", `No message "${messageId}" is loaded.`);
|
|
1096
|
+
return jid;
|
|
1097
|
+
}
|
|
1098
|
+
viewOf(sid, chatJid) {
|
|
1099
|
+
const raw = this.messageOrThrow(sid);
|
|
1100
|
+
return buildMessageView(raw, {
|
|
1101
|
+
canonical: (jid) => this.canonical(jid),
|
|
1102
|
+
nameFor: (jid) => this.nameFor(jid),
|
|
1103
|
+
ownId: this.ownJid(),
|
|
1104
|
+
chatId: chatJid,
|
|
1105
|
+
edited: this.store.edited.has(sid),
|
|
1106
|
+
reactions: this.store.reactionsFor(sid),
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
viewsFor(sids, chatJid) {
|
|
1110
|
+
return sids.filter((sid) => this.store.messages.has(sid)).map((sid) => this.viewOf(sid, chatJid));
|
|
1111
|
+
}
|
|
1112
|
+
olderThan(chatJid, before, limit) {
|
|
1113
|
+
const ring = this.store.byChat.get(chatJid) ?? [];
|
|
1114
|
+
const at = ring.indexOf(before);
|
|
1115
|
+
if (at <= 0)
|
|
1116
|
+
return [];
|
|
1117
|
+
return ring.slice(Math.max(0, at - limit), at);
|
|
1118
|
+
}
|
|
1119
|
+
async fetchOlder(sock, anchor, limit) {
|
|
1120
|
+
const seconds = Math.floor(messageTimestampMs(anchor) / 1000);
|
|
1121
|
+
await sock.fetchMessageHistory(limit, anchor.key, seconds);
|
|
1122
|
+
await new Promise((done) => {
|
|
1123
|
+
const timer = setTimeout(() => {
|
|
1124
|
+
this.historyWaiters = this.historyWaiters.filter((entry) => entry !== waiter);
|
|
1125
|
+
done();
|
|
1126
|
+
}, HISTORY_FETCH_WAIT_MS);
|
|
1127
|
+
const waiter = () => {
|
|
1128
|
+
clearTimeout(timer);
|
|
1129
|
+
done();
|
|
1130
|
+
};
|
|
1131
|
+
this.historyWaiters.push(waiter);
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
lastMessageOf(chatJid) {
|
|
1135
|
+
const ring = this.store.byChat.get(chatJid);
|
|
1136
|
+
const last = ring && ring.length > 0 ? this.store.messages.get(ring[ring.length - 1]) : undefined;
|
|
1137
|
+
return last ?? null;
|
|
1138
|
+
}
|
|
1139
|
+
chatActivity(chat) {
|
|
1140
|
+
return protoNumber(chat.conversationTimestamp) ?? 0;
|
|
1141
|
+
}
|
|
1142
|
+
matchesChatFilter(chat, filter) {
|
|
1143
|
+
const archived = Boolean(chat.archived);
|
|
1144
|
+
const group = isGroupId(chat.id ?? "");
|
|
1145
|
+
switch (filter) {
|
|
1146
|
+
case "unread":
|
|
1147
|
+
return !archived && (chat.unreadCount ?? 0) > 0;
|
|
1148
|
+
case "groups":
|
|
1149
|
+
return !archived && group;
|
|
1150
|
+
case "individual":
|
|
1151
|
+
return !archived && !group;
|
|
1152
|
+
case "archived":
|
|
1153
|
+
return archived;
|
|
1154
|
+
case "all":
|
|
1155
|
+
return !archived;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
chatSummary(chat) {
|
|
1159
|
+
const jid = this.canonical(chat.id ?? "");
|
|
1160
|
+
const last = this.lastMessageOf(jid);
|
|
1161
|
+
const muteEnd = protoNumber(chat.muteEndTime) ?? 0;
|
|
1162
|
+
const summary = {
|
|
1163
|
+
chat_id: jid,
|
|
1164
|
+
name: chat.name || this.nameFor(jid),
|
|
1165
|
+
type: isGroupId(jid) ? "group" : "individual",
|
|
1166
|
+
unread_count: Math.max(0, chat.unreadCount ?? 0),
|
|
1167
|
+
last_message: last
|
|
1168
|
+
? {
|
|
1169
|
+
text: messageText(last),
|
|
1170
|
+
timestamp: isoWithOffset(messageTimestampMs(last)),
|
|
1171
|
+
from_me: Boolean(last.key.fromMe),
|
|
1172
|
+
}
|
|
1173
|
+
: null,
|
|
1174
|
+
archived: Boolean(chat.archived),
|
|
1175
|
+
pinned: Boolean(chat.pinned),
|
|
1176
|
+
muted_until: muteEnd > Date.now() ? isoWithOffset(muteEnd) : null,
|
|
1177
|
+
};
|
|
1178
|
+
// A group we left is delivered as read-only; individual chats never are.
|
|
1179
|
+
if (isGroupId(jid) && chat.readOnly)
|
|
1180
|
+
summary.left = true;
|
|
1181
|
+
return summary;
|
|
1182
|
+
}
|
|
1183
|
+
contactSummary(jid, contact) {
|
|
1184
|
+
const number = jid.endsWith("@s.whatsapp.net") ? (jid.split("@")[0] ?? null) : null;
|
|
1185
|
+
return {
|
|
1186
|
+
contact_id: jid,
|
|
1187
|
+
name: contact?.name || contact?.notify || number || jid,
|
|
1188
|
+
number,
|
|
1189
|
+
is_my_contact: Boolean(contact?.name),
|
|
1190
|
+
is_business: Boolean(contact?.verifiedName),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
sentResult(sent, jid, text) {
|
|
1194
|
+
if (!sent) {
|
|
1195
|
+
return { message_id: `unknown_${jid}_${randomUUID()}`, chat_id: jid, text, timestamp: isoWithOffset(Date.now()) };
|
|
1196
|
+
}
|
|
1197
|
+
const sid = messageIdFor(sent.key, jid);
|
|
1198
|
+
this.store.putMessage(sid, jid, sent);
|
|
1199
|
+
this.markStoreDirty();
|
|
1200
|
+
return { message_id: sid, chat_id: jid, text, timestamp: isoWithOffset(messageTimestampMs(sent)) };
|
|
1201
|
+
}
|
|
1202
|
+
ingestChat(chat) {
|
|
1203
|
+
if (!chat.id)
|
|
1204
|
+
return;
|
|
1205
|
+
if (chat.lidJid && chat.pnJid)
|
|
1206
|
+
this.learnLid(chat.lidJid, chat.pnJid);
|
|
1207
|
+
const jid = this.canonical(chat.id);
|
|
1208
|
+
const previous = this.store.chats.get(jid);
|
|
1209
|
+
this.store.chats.set(jid, { ...(previous ?? {}), ...chat, id: jid });
|
|
1210
|
+
}
|
|
1211
|
+
ingestContact(contact) {
|
|
1212
|
+
if (!contact.id)
|
|
1213
|
+
return;
|
|
1214
|
+
if (contact.lid && contact.phoneNumber)
|
|
1215
|
+
this.learnLid(contact.lid, contact.phoneNumber);
|
|
1216
|
+
const jid = this.canonical(contact.id);
|
|
1217
|
+
const previous = this.store.contacts.get(jid);
|
|
1218
|
+
this.store.contacts.set(jid, { ...(previous ?? {}), ...contact, id: jid });
|
|
1219
|
+
}
|
|
1220
|
+
ingestMessages(messages) {
|
|
1221
|
+
const stored = [];
|
|
1222
|
+
for (const raw of messages) {
|
|
1223
|
+
if (!raw.message || !raw.key?.remoteJid)
|
|
1224
|
+
continue;
|
|
1225
|
+
const jid = this.canonical(raw.key.remoteJid);
|
|
1226
|
+
if (jid === "status@broadcast")
|
|
1227
|
+
continue;
|
|
1228
|
+
this.store.putMessage(messageIdFor(raw.key, jid), jid, raw);
|
|
1229
|
+
stored.push(raw);
|
|
1230
|
+
}
|
|
1231
|
+
return stored;
|
|
1232
|
+
}
|
|
1233
|
+
async loadPersisted() {
|
|
1234
|
+
if (!this.config.persistHistory || this.persistedLoaded)
|
|
1235
|
+
return;
|
|
1236
|
+
this.persistedLoaded = true;
|
|
1237
|
+
await this.loadHistoryStore();
|
|
1238
|
+
await this.loadStoreSnapshot();
|
|
1239
|
+
}
|
|
1240
|
+
async loadStoreSnapshot() {
|
|
1241
|
+
try {
|
|
1242
|
+
const text = await readFile(this.paths.storeFile, "utf8");
|
|
1243
|
+
this.store.hydrate(JSON.parse(text));
|
|
1244
|
+
log(`store loaded: ${this.store.chats.size} chats, ${this.store.messages.size} messages`);
|
|
1245
|
+
}
|
|
1246
|
+
catch (err) {
|
|
1247
|
+
if (!isMissing(err))
|
|
1248
|
+
logError("store load", err);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
markStoreDirty() {
|
|
1252
|
+
if (!this.config.persistHistory)
|
|
1253
|
+
return;
|
|
1254
|
+
this.storeDirty = true;
|
|
1255
|
+
if (this.storeSaveTimer)
|
|
1256
|
+
return;
|
|
1257
|
+
this.storeSaveTimer = setTimeout(() => {
|
|
1258
|
+
this.storeSaveTimer = null;
|
|
1259
|
+
void this.flushStore();
|
|
1260
|
+
}, STORE_SAVE_DEBOUNCE_MS);
|
|
1261
|
+
}
|
|
1262
|
+
async flushStore() {
|
|
1263
|
+
if (!this.config.persistHistory || !this.storeDirty)
|
|
1264
|
+
return;
|
|
1265
|
+
this.storeDirty = false;
|
|
1266
|
+
try {
|
|
1267
|
+
await mkdir(this.paths.dataDir, { recursive: true, mode: DIR_MODE });
|
|
1268
|
+
const tmp = `${this.paths.storeFile}.tmp`;
|
|
1269
|
+
await writeFile(tmp, JSON.stringify(this.store.serialize()), { mode: FILE_MODE });
|
|
1270
|
+
await rename(tmp, this.paths.storeFile);
|
|
1271
|
+
}
|
|
1272
|
+
catch (err) {
|
|
1273
|
+
logError("store save", err);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async loadHistoryStore() {
|
|
1277
|
+
try {
|
|
1278
|
+
await mkdir(this.paths.historyDir, { recursive: true, mode: DIR_MODE });
|
|
1279
|
+
const files = (await readdir(this.paths.historyDir)).filter((name) => name.endsWith(".jsonl"));
|
|
1280
|
+
let loaded = 0;
|
|
1281
|
+
for (const name of files)
|
|
1282
|
+
loaded += await this.loadHistoryFile(join(this.paths.historyDir, name));
|
|
1283
|
+
if (loaded > 0)
|
|
1284
|
+
log(`history store loaded: ${loaded} messages`);
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
logError("history load", err);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
async loadHistoryFile(path) {
|
|
1291
|
+
let text;
|
|
1292
|
+
try {
|
|
1293
|
+
text = await readFile(path, "utf8");
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
if (!isMissing(err))
|
|
1297
|
+
logError("history load", err);
|
|
1298
|
+
return 0;
|
|
1299
|
+
}
|
|
1300
|
+
const newest = new Map();
|
|
1301
|
+
for (const line of text.split("\n")) {
|
|
1302
|
+
if (!line.trim())
|
|
1303
|
+
continue;
|
|
1304
|
+
try {
|
|
1305
|
+
const record = JSON.parse(line);
|
|
1306
|
+
if (record.sid && record.raw)
|
|
1307
|
+
newest.set(record.sid, record);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
const kept = [...newest.values()].sort((a, b) => a.ts - b.ts).slice(-HISTORY_STORE_CAP_PER_CHAT);
|
|
1314
|
+
// Rewrite compacted, so the file stays bounded across restarts.
|
|
1315
|
+
const compacted = kept.map((record) => JSON.stringify(record)).join("\n");
|
|
1316
|
+
const tmp = `${path}.tmp`;
|
|
1317
|
+
await writeFile(tmp, kept.length > 0 ? `${compacted}\n` : "", { mode: FILE_MODE });
|
|
1318
|
+
await rename(tmp, path);
|
|
1319
|
+
let loaded = 0;
|
|
1320
|
+
for (const record of kept) {
|
|
1321
|
+
const raw = decodeMessage(record.raw);
|
|
1322
|
+
if (!raw?.message || !raw.key?.remoteJid)
|
|
1323
|
+
continue;
|
|
1324
|
+
this.store.putMessage(record.sid, this.canonical(raw.key.remoteJid), raw);
|
|
1325
|
+
loaded++;
|
|
1326
|
+
}
|
|
1327
|
+
return loaded;
|
|
1328
|
+
}
|
|
1329
|
+
async appendHistory(messages) {
|
|
1330
|
+
if (!this.config.persistHistory || messages.length === 0)
|
|
1331
|
+
return;
|
|
1332
|
+
const lines = new Map();
|
|
1333
|
+
for (const raw of messages) {
|
|
1334
|
+
if (!raw.message || !raw.key?.remoteJid)
|
|
1335
|
+
continue;
|
|
1336
|
+
const encoded = encode(() => proto.WebMessageInfo.encode(raw).finish());
|
|
1337
|
+
if (!encoded)
|
|
1338
|
+
continue;
|
|
1339
|
+
const jid = this.canonical(raw.key.remoteJid);
|
|
1340
|
+
const record = {
|
|
1341
|
+
sid: messageIdFor(raw.key, jid),
|
|
1342
|
+
ts: Math.floor(messageTimestampMs(raw) / 1000),
|
|
1343
|
+
raw: encoded,
|
|
1344
|
+
};
|
|
1345
|
+
const bucket = lines.get(jid) ?? [];
|
|
1346
|
+
bucket.push(JSON.stringify(record));
|
|
1347
|
+
lines.set(jid, bucket);
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
await mkdir(this.paths.historyDir, { recursive: true, mode: DIR_MODE });
|
|
1351
|
+
for (const [jid, bucket] of lines) {
|
|
1352
|
+
const path = join(this.paths.historyDir, `${safeFilename(jid)}.jsonl`);
|
|
1353
|
+
await appendFile(path, `${bucket.join("\n")}\n`, { mode: FILE_MODE });
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
catch (err) {
|
|
1357
|
+
logError("history append", err);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const ADMIN_ACTIONS = new Set([
|
|
1362
|
+
"add",
|
|
1363
|
+
"remove",
|
|
1364
|
+
"promote",
|
|
1365
|
+
"demote",
|
|
1366
|
+
"set_subject",
|
|
1367
|
+
"set_description",
|
|
1368
|
+
"get_invite_link",
|
|
1369
|
+
"revoke_invite_link",
|
|
1370
|
+
]);
|
|
1371
|
+
const PARTICIPANT_ACTIONS = new Set(["add", "remove", "promote", "demote"]);
|
|
1372
|
+
/** WhatsApp answers "cannot add, invite them instead" with these codes. */
|
|
1373
|
+
const INVITE_NEEDED_CODES = new Set(["403", "409"]);
|
|
1374
|
+
function isAdmin(participant) {
|
|
1375
|
+
return participant.admin === "admin" || participant.admin === "superadmin";
|
|
1376
|
+
}
|
|
1377
|
+
function requireValue(value, action, what) {
|
|
1378
|
+
const trimmed = (value ?? "").trim();
|
|
1379
|
+
if (!trimmed)
|
|
1380
|
+
throw new WazapError("INVALID_ID", `The "${action}" action needs a value: ${what}.`);
|
|
1381
|
+
return trimmed;
|
|
1382
|
+
}
|
|
1383
|
+
function describe(err) {
|
|
1384
|
+
return err instanceof Error ? err.message : String(err);
|
|
1385
|
+
}
|
|
1386
|
+
function isMissing(err) {
|
|
1387
|
+
return err?.code === "ENOENT";
|
|
1388
|
+
}
|
|
1389
|
+
function statusCodeOf(err) {
|
|
1390
|
+
return err?.output?.statusCode;
|
|
1391
|
+
}
|
|
1392
|
+
function statusTextOf(entry) {
|
|
1393
|
+
const status = entry?.status;
|
|
1394
|
+
if (status && typeof status === "object" && "status" in status) {
|
|
1395
|
+
return status.status ?? null;
|
|
1396
|
+
}
|
|
1397
|
+
return typeof status === "string" ? status : null;
|
|
1398
|
+
}
|
|
1399
|
+
function encode(run) {
|
|
1400
|
+
try {
|
|
1401
|
+
return Buffer.from(run()).toString("base64");
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function decodeMessage(b64) {
|
|
1408
|
+
try {
|
|
1409
|
+
return proto.WebMessageInfo.decode(Buffer.from(b64, "base64"));
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
function decodeChat(b64) {
|
|
1416
|
+
try {
|
|
1417
|
+
return proto.Conversation.decode(Buffer.from(b64, "base64"));
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function safeFilename(jid) {
|
|
1424
|
+
return jid.replace(/[/\\:*?"<>|]/g, "_");
|
|
1425
|
+
}
|
|
1426
|
+
function mediaFilename(info) {
|
|
1427
|
+
const original = (info.filename ?? "").replace(/[^\w.-]/g, "_");
|
|
1428
|
+
const fromName = original.includes(".") ? original.slice(original.lastIndexOf(".")) : "";
|
|
1429
|
+
const subtype = info.mime.split("/")[1]?.split(";")[0] ?? "bin";
|
|
1430
|
+
return `${Date.now()}-${randomUUID().slice(0, 8)}${fromName || `.${subtype}`}`;
|
|
1431
|
+
}
|
|
1432
|
+
async function loadMedia(source) {
|
|
1433
|
+
const hasPath = Boolean(source.file_path);
|
|
1434
|
+
const hasUrl = Boolean(source.url);
|
|
1435
|
+
if (hasPath === hasUrl) {
|
|
1436
|
+
throw new WazapError("FILE_NOT_FOUND", "Provide exactly one of file_path or url.");
|
|
1437
|
+
}
|
|
1438
|
+
if (source.file_path) {
|
|
1439
|
+
const path = source.file_path;
|
|
1440
|
+
let size;
|
|
1441
|
+
try {
|
|
1442
|
+
size = (await stat(path)).size;
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
throw new WazapError("FILE_NOT_FOUND", `No file at "${path}" on the machine running wazap.`);
|
|
1446
|
+
}
|
|
1447
|
+
assertMediaSize(size);
|
|
1448
|
+
return { buffer: await readFile(path), mimetype: guessMime(path), filename: basename(path) };
|
|
1449
|
+
}
|
|
1450
|
+
const url = source.url;
|
|
1451
|
+
let response;
|
|
1452
|
+
try {
|
|
1453
|
+
response = await fetch(url);
|
|
1454
|
+
}
|
|
1455
|
+
catch (err) {
|
|
1456
|
+
throw new WazapError("URL_FETCH_FAILED", `Could not fetch ${url}: ${describe(err)}`);
|
|
1457
|
+
}
|
|
1458
|
+
if (!response.ok) {
|
|
1459
|
+
throw new WazapError("URL_FETCH_FAILED", `Fetching ${url} returned HTTP ${response.status}.`);
|
|
1460
|
+
}
|
|
1461
|
+
const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
|
1462
|
+
if (Number.isFinite(declared))
|
|
1463
|
+
assertMediaSize(declared);
|
|
1464
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1465
|
+
assertMediaSize(buffer.length);
|
|
1466
|
+
return {
|
|
1467
|
+
buffer,
|
|
1468
|
+
mimetype: response.headers.get("content-type")?.split(";")[0] ?? guessMime(url),
|
|
1469
|
+
filename: basename(url.split("?")[0] ?? url),
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
function assertMediaSize(size) {
|
|
1473
|
+
if (size > MAX_MEDIA_BYTES) {
|
|
1474
|
+
throw new WazapError("FILE_TOO_LARGE", `The file is ${Math.round(size / 1_048_576)} MB; WhatsApp allows 100 MB.`);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function basename(path) {
|
|
1478
|
+
return path.split(/[/\\]/).pop() || "file";
|
|
1479
|
+
}
|
|
1480
|
+
function mediaContent(media, opts) {
|
|
1481
|
+
const { buffer, mimetype, filename } = media;
|
|
1482
|
+
if (opts.asVoice)
|
|
1483
|
+
return { audio: buffer, mimetype: "audio/ogg; codecs=opus", ptt: true };
|
|
1484
|
+
if (opts.asDocument)
|
|
1485
|
+
return { document: buffer, mimetype, fileName: filename, caption: opts.caption };
|
|
1486
|
+
if (mimetype.startsWith("image/"))
|
|
1487
|
+
return { image: buffer, caption: opts.caption };
|
|
1488
|
+
if (mimetype.startsWith("video/"))
|
|
1489
|
+
return { video: buffer, caption: opts.caption };
|
|
1490
|
+
if (mimetype.startsWith("audio/"))
|
|
1491
|
+
return { audio: buffer, mimetype };
|
|
1492
|
+
return { document: buffer, mimetype, fileName: filename, caption: opts.caption };
|
|
1493
|
+
}
|
|
1494
|
+
const MIME_BY_EXTENSION = {
|
|
1495
|
+
jpg: "image/jpeg",
|
|
1496
|
+
jpeg: "image/jpeg",
|
|
1497
|
+
png: "image/png",
|
|
1498
|
+
gif: "image/gif",
|
|
1499
|
+
webp: "image/webp",
|
|
1500
|
+
mp4: "video/mp4",
|
|
1501
|
+
mov: "video/quicktime",
|
|
1502
|
+
mp3: "audio/mpeg",
|
|
1503
|
+
m4a: "audio/mp4",
|
|
1504
|
+
ogg: "audio/ogg",
|
|
1505
|
+
opus: "audio/ogg",
|
|
1506
|
+
pdf: "application/pdf",
|
|
1507
|
+
txt: "text/plain",
|
|
1508
|
+
csv: "text/csv",
|
|
1509
|
+
zip: "application/zip",
|
|
1510
|
+
};
|
|
1511
|
+
function guessMime(path) {
|
|
1512
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
1513
|
+
return MIME_BY_EXTENSION[ext] ?? "application/octet-stream";
|
|
1514
|
+
}
|