whats-mcp 0.1.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/src/store.js ADDED
@@ -0,0 +1,925 @@
1
+ /**
2
+ * whats-mcp — In-memory store.
3
+ *
4
+ * Captures Baileys events to maintain a searchable cache of chats,
5
+ * contacts, messages, and group metadata.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const { formatMessage, isGroupJid } = require("./helpers");
12
+
13
+ const ANALYTICS_STOP_WORDS = new Set([
14
+ "a", "an", "and", "are", "as", "at", "be", "by", "de", "des", "du", "en", "est",
15
+ "et", "for", "how", "il", "in", "is", "je", "la", "le", "les", "mais", "of", "on",
16
+ "or", "ou", "pour", "que", "qui", "the", "to", "tu", "un", "une", "vous", "with",
17
+ ]);
18
+
19
+ class Store {
20
+ /**
21
+ * @param {{ max_messages_per_chat?: number, max_chats?: number }} opts
22
+ */
23
+ constructor(opts = {}) {
24
+ this.maxMessagesPerChat = opts.max_messages_per_chat || 500;
25
+ this.maxChats = opts.max_chats || 1000;
26
+ this.onChange = typeof opts.onChange === "function" ? opts.onChange : null;
27
+
28
+ /** @type {Map<string, object>} jid → chat */
29
+ this.chats = new Map();
30
+
31
+ /** @type {Map<string, object>} jid → contact */
32
+ this.contacts = new Map();
33
+
34
+ /** @type {Map<string, object[]>} jid → messages (newest last) */
35
+ this.messages = new Map();
36
+
37
+ /** @type {Map<string, object>} jid → group metadata cache */
38
+ this.groupMeta = new Map();
39
+
40
+ /** @type {Map<string, object>} msgId → message (for quick lookup) */
41
+ this.messageIndex = new Map();
42
+
43
+ /** @type {object | null} lazily built analytics cache */
44
+ this.analyticsCache = null;
45
+
46
+ /** @type {Map<string, string[]>} jid → custom tags */
47
+ this.contactTags = new Map();
48
+
49
+ /** @type {Map<string, string[]>} name → JID array (dynamic, persisted watchlists) */
50
+ this.watchlists = new Map();
51
+ }
52
+
53
+ // ── Chat operations ──────────────────────────────────────────────────────
54
+
55
+ upsertChats(chats) {
56
+ for (const chat of chats) {
57
+ const existing = this.chats.get(chat.id) || {};
58
+ this.chats.set(chat.id, { ...existing, ...chat });
59
+ }
60
+ this._trimChats();
61
+ this._notifyChanged();
62
+ }
63
+
64
+ updateChats(updates) {
65
+ for (const update of updates) {
66
+ const existing = this.chats.get(update.id);
67
+ if (existing) {
68
+ Object.assign(existing, update);
69
+ }
70
+ }
71
+ this._notifyChanged();
72
+ }
73
+
74
+ deleteChats(ids) {
75
+ for (const id of ids) {
76
+ this.chats.delete(id);
77
+ this.messages.delete(id);
78
+ }
79
+ this._notifyChanged();
80
+ }
81
+
82
+ getChat(jid) {
83
+ return this.chats.get(jid) || null;
84
+ }
85
+
86
+ listChats(limit = 50, offset = 0) {
87
+ const all = Array.from(this.chats.values());
88
+ // Sort by timestamp descending
89
+ all.sort((a, b) => {
90
+ const ta = Number(a.conversationTimestamp || 0);
91
+ const tb = Number(b.conversationTimestamp || 0);
92
+ return tb - ta;
93
+ });
94
+ return all.slice(offset, offset + limit);
95
+ }
96
+
97
+ // ── Contact operations ───────────────────────────────────────────────────
98
+
99
+ upsertContacts(contacts) {
100
+ for (const contact of contacts) {
101
+ const jid = contact.id;
102
+ if (!jid) continue;
103
+ const existing = this.contacts.get(jid) || {};
104
+ this.contacts.set(jid, { ...existing, ...contact });
105
+ }
106
+ this._notifyChanged();
107
+ }
108
+
109
+ updateContacts(updates) {
110
+ for (const update of updates) {
111
+ const jid = update.id;
112
+ if (!jid) continue;
113
+ const existing = this.contacts.get(jid);
114
+ if (existing) {
115
+ Object.assign(existing, update);
116
+ }
117
+ }
118
+ this._notifyChanged();
119
+ }
120
+
121
+ getContact(jid) {
122
+ return this.contacts.get(jid) || null;
123
+ }
124
+
125
+ listContacts(options = {}) {
126
+ let contacts = Array.from(this.contacts.values());
127
+ if (options.name) {
128
+ const lower = options.name.toLowerCase();
129
+ contacts = contacts.filter((c) => {
130
+ const name = (c.name || c.notify || c.verifiedName || c.short || "").toLowerCase();
131
+ return name.includes(lower);
132
+ });
133
+ }
134
+ if (options.tag) {
135
+ const taggedJids = new Set(this.listByTag(options.tag));
136
+ contacts = contacts.filter((c) => taggedJids.has(c.id));
137
+ }
138
+ if (options.has_tags !== undefined) {
139
+ contacts = options.has_tags
140
+ ? contacts.filter((c) => (this.contactTags.get(c.id) || []).length > 0)
141
+ : contacts.filter((c) => (this.contactTags.get(c.id) || []).length === 0);
142
+ }
143
+ return contacts;
144
+ }
145
+
146
+ // ── Contact tags ─────────────────────────────────────────────────────────
147
+
148
+ setContactTags(jid, tags) {
149
+ this.contactTags.set(jid, [...new Set(tags)]);
150
+ this._notifyChanged();
151
+ }
152
+
153
+ addContactTags(jid, tags) {
154
+ const existing = this.contactTags.get(jid) || [];
155
+ this.contactTags.set(jid, [...new Set([...existing, ...tags])]);
156
+ this._notifyChanged();
157
+ }
158
+
159
+ removeContactTags(jid, tags) {
160
+ const existing = this.contactTags.get(jid) || [];
161
+ const filtered = existing.filter((t) => !tags.includes(t));
162
+ if (filtered.length > 0) {
163
+ this.contactTags.set(jid, filtered);
164
+ } else {
165
+ this.contactTags.delete(jid);
166
+ }
167
+ this._notifyChanged();
168
+ }
169
+
170
+ getContactTags(jid) {
171
+ return this.contactTags.get(jid) || [];
172
+ }
173
+
174
+ listByTag(tag) {
175
+ const results = [];
176
+ for (const [jid, tags] of this.contactTags) {
177
+ if (tags.includes(tag)) results.push(jid);
178
+ }
179
+ return results;
180
+ }
181
+
182
+ getAllTags() {
183
+ const tags = new Set();
184
+ for (const tagList of this.contactTags.values()) {
185
+ for (const t of tagList) tags.add(t);
186
+ }
187
+ return Array.from(tags).sort();
188
+ }
189
+
190
+ // ── Watchlist operations ──────────────────────────────────────────────────
191
+
192
+ setWatchlist(name, jids) {
193
+ this.watchlists.set(name, [...new Set(jids)]);
194
+ this._notifyChanged();
195
+ }
196
+
197
+ addToWatchlist(name, jids) {
198
+ const existing = this.watchlists.get(name) || [];
199
+ this.watchlists.set(name, [...new Set([...existing, ...jids])]);
200
+ this._notifyChanged();
201
+ }
202
+
203
+ removeFromWatchlist(name, jids) {
204
+ const existing = this.watchlists.get(name) || [];
205
+ const jidSet = new Set(jids);
206
+ const filtered = existing.filter((j) => !jidSet.has(j));
207
+ if (filtered.length > 0) {
208
+ this.watchlists.set(name, filtered);
209
+ } else {
210
+ this.watchlists.delete(name);
211
+ }
212
+ this._notifyChanged();
213
+ }
214
+
215
+ deleteWatchlist(name) {
216
+ const existed = this.watchlists.has(name);
217
+ this.watchlists.delete(name);
218
+ if (existed) this._notifyChanged();
219
+ return existed;
220
+ }
221
+
222
+ getWatchlist(name) {
223
+ return this.watchlists.get(name) || null;
224
+ }
225
+
226
+ listWatchlists() {
227
+ return Object.fromEntries(this.watchlists);
228
+ }
229
+
230
+ /**
231
+ * Resolve a watchlist name → JID array.
232
+ * Checks the dynamic store first, then falls back to config watchlists.
233
+ */
234
+ resolveWatchlist(name, configWatchlists = {}) {
235
+ return this.watchlists.get(name) || configWatchlists[name] || null;
236
+ }
237
+
238
+ /**
239
+ * Import watchlists from config into the store (one-time bootstrap).
240
+ * Only imports names not already present in the store.
241
+ * @returns {number} number of watchlists imported
242
+ */
243
+ importWatchlistsFromConfig(configWatchlists = {}) {
244
+ let imported = 0;
245
+ for (const [name, jids] of Object.entries(configWatchlists)) {
246
+ if (Array.isArray(jids) && !this.watchlists.has(name)) {
247
+ this.watchlists.set(name, [...new Set(jids)]);
248
+ imported++;
249
+ }
250
+ }
251
+ if (imported > 0) this._notifyChanged();
252
+ return imported;
253
+ }
254
+
255
+ // ── Message operations ───────────────────────────────────────────────────
256
+
257
+ upsertMessages(messages) {
258
+ for (const msg of messages) {
259
+ const jid = msg.key?.remoteJid;
260
+ if (!jid) continue;
261
+
262
+ this._touchChatFromMessage(msg);
263
+
264
+ if (!this.messages.has(jid)) {
265
+ this.messages.set(jid, []);
266
+ }
267
+ const arr = this.messages.get(jid);
268
+
269
+ // Dedup by message id
270
+ const existing = arr.findIndex((m) => m.key?.id === msg.key?.id);
271
+ if (existing >= 0) {
272
+ arr[existing] = msg;
273
+ } else {
274
+ arr.push(msg);
275
+ }
276
+
277
+ // Index for quick lookup
278
+ if (msg.key?.id) {
279
+ this.messageIndex.set(msg.key.id, msg);
280
+ }
281
+
282
+ arr.sort((a, b) => {
283
+ const ta = Number(a.messageTimestamp || 0);
284
+ const tb = Number(b.messageTimestamp || 0);
285
+ if (ta !== tb) return ta - tb;
286
+ return String(a.key?.id || "").localeCompare(String(b.key?.id || ""));
287
+ });
288
+
289
+ // Trim
290
+ if (arr.length > this.maxMessagesPerChat) {
291
+ const removed = arr.splice(0, arr.length - this.maxMessagesPerChat);
292
+ for (const r of removed) {
293
+ if (r.key?.id) this.messageIndex.delete(r.key.id);
294
+ }
295
+ }
296
+ }
297
+ this._notifyChanged();
298
+ }
299
+
300
+ deleteMessages(keys) {
301
+ for (const key of keys) {
302
+ const jid = key.remoteJid;
303
+ const arr = this.messages.get(jid);
304
+ if (arr) {
305
+ const idx = arr.findIndex((m) => m.key?.id === key.id);
306
+ if (idx >= 0) {
307
+ arr.splice(idx, 1);
308
+ }
309
+ }
310
+ this.messageIndex.delete(key.id);
311
+ }
312
+ this._notifyChanged();
313
+ }
314
+
315
+ getMessages(jid, limit = 50, before_id, options = {}) {
316
+ const arr = this.messages.get(jid) || [];
317
+ let result = [...arr];
318
+
319
+ // Sort by timestamp descending
320
+ result.sort((a, b) => {
321
+ const ta = Number(a.messageTimestamp || 0);
322
+ const tb = Number(b.messageTimestamp || 0);
323
+ return tb - ta;
324
+ });
325
+
326
+ if (before_id) {
327
+ const idx = result.findIndex((m) => m.key?.id === before_id);
328
+ if (idx >= 0) {
329
+ result = result.slice(idx + 1);
330
+ }
331
+ }
332
+
333
+ result = this._applyMessageFilters(result, options);
334
+ return result.slice(0, limit);
335
+ }
336
+
337
+ countMessages(jid) {
338
+ return (this.messages.get(jid) || []).length;
339
+ }
340
+
341
+ getOldestMessage(jid) {
342
+ const arr = this.messages.get(jid) || [];
343
+ if (arr.length === 0) return null;
344
+
345
+ return [...arr].sort((a, b) => {
346
+ const ta = Number(a.messageTimestamp || 0);
347
+ const tb = Number(b.messageTimestamp || 0);
348
+ return ta - tb;
349
+ })[0] || null;
350
+ }
351
+
352
+ getMessage(id) {
353
+ return this.messageIndex.get(id) || null;
354
+ }
355
+
356
+ /**
357
+ * Search messages across all chats.
358
+ * @param {string} query - text to search for (case-insensitive)
359
+ * @param {string} [jid] - restrict to specific chat
360
+ * @param {number} [limit=20]
361
+ */
362
+ searchMessages(query, jid, limit = 20, options = {}) {
363
+ const lower = query.toLowerCase();
364
+ const results = [];
365
+
366
+ // Support single JID string, array of JIDs, or null/undefined (all)
367
+ let chatJids;
368
+ if (Array.isArray(jid)) {
369
+ chatJids = jid;
370
+ } else if (jid) {
371
+ chatJids = [jid];
372
+ } else {
373
+ chatJids = Array.from(this.messages.keys());
374
+ }
375
+
376
+ for (const chatJid of chatJids) {
377
+ let msgs = this.messages.get(chatJid) || [];
378
+ msgs = this._applyMessageFilters(msgs, options);
379
+ for (const msg of msgs) {
380
+ if (results.length >= limit) break;
381
+ const formatted = formatMessage(msg);
382
+ if (formatted && formatted.text.toLowerCase().includes(lower)) {
383
+ results.push(formatted);
384
+ }
385
+ }
386
+ if (results.length >= limit) break;
387
+ }
388
+
389
+ return results;
390
+ }
391
+
392
+ // ── Group metadata cache ─────────────────────────────────────────────────
393
+
394
+ setGroupMeta(jid, meta) {
395
+ this.groupMeta.set(jid, meta);
396
+ if (Array.isArray(meta?.participants) && meta.participants.length > 0) {
397
+ this.upsertContacts(
398
+ meta.participants
399
+ .filter((participant) => participant?.id)
400
+ .map((participant) => ({
401
+ id: participant.id,
402
+ admin: participant.admin || null,
403
+ }))
404
+ );
405
+ }
406
+ const chat = this.chats.get(jid) || { id: jid };
407
+ this.chats.set(jid, {
408
+ ...chat,
409
+ id: jid,
410
+ name: meta?.subject || chat.name,
411
+ subject: meta?.subject || chat.subject,
412
+ conversationTimestamp:
413
+ Number(chat.conversationTimestamp || 0) ||
414
+ Number(meta?.subjectTime || 0) ||
415
+ Number(meta?.creation || 0) ||
416
+ undefined,
417
+ });
418
+ this._trimChats();
419
+ this._notifyChanged();
420
+ }
421
+
422
+ getGroupMeta(jid) {
423
+ return this.groupMeta.get(jid) || null;
424
+ }
425
+
426
+ // ── History sync ─────────────────────────────────────────────────────────
427
+
428
+ /**
429
+ * Handle the `messaging-history.set` event.
430
+ */
431
+ handleHistorySync({ chats, contacts, messages }) {
432
+ if (chats) this.upsertChats(chats);
433
+ if (contacts) this.upsertContacts(contacts);
434
+ if (messages) {
435
+ // messages from history are wrapped: { message, ... }
436
+ const flat = messages.map((m) => m.message || m).filter(Boolean);
437
+ this.upsertMessages(flat);
438
+ }
439
+ }
440
+
441
+ saveSnapshot(filePath) {
442
+ const snapshot = {
443
+ chats: Array.from(this.chats.values()),
444
+ contacts: Array.from(this.contacts.values()),
445
+ messages: Array.from(this.messages.entries()),
446
+ groupMeta: Array.from(this.groupMeta.entries()),
447
+ contactTags: Object.fromEntries(this.contactTags),
448
+ watchlists: Object.fromEntries(this.watchlists),
449
+ };
450
+ fs.writeFileSync(filePath, JSON.stringify(snapshot), "utf-8");
451
+ }
452
+
453
+ loadSnapshot(filePath) {
454
+ if (!fs.existsSync(filePath)) return false;
455
+
456
+ const raw = fs.readFileSync(filePath, "utf-8");
457
+ const snapshot = JSON.parse(raw);
458
+
459
+ this.chats = new Map((snapshot.chats || []).map((chat) => [chat.id, chat]));
460
+ this.contacts = new Map((snapshot.contacts || []).map((contact) => [contact.id, contact]));
461
+ this.messages = new Map(snapshot.messages || []);
462
+ this.groupMeta = new Map(snapshot.groupMeta || []);
463
+ this.contactTags = new Map(Object.entries(snapshot.contactTags || {}));
464
+ this.watchlists = new Map(Object.entries(snapshot.watchlists || {}));
465
+ this.messageIndex = new Map();
466
+
467
+ for (const msgList of this.messages.values()) {
468
+ msgList.sort((a, b) => {
469
+ const ta = Number(a.messageTimestamp || 0);
470
+ const tb = Number(b.messageTimestamp || 0);
471
+ if (ta !== tb) return ta - tb;
472
+ return String(a.key?.id || "").localeCompare(String(b.key?.id || ""));
473
+ });
474
+
475
+ for (const msg of msgList) {
476
+ if (msg?.key?.id) {
477
+ this.messageIndex.set(msg.key.id, msg);
478
+ }
479
+ }
480
+ }
481
+
482
+ this._trimChats();
483
+ for (const [jid, arr] of this.messages.entries()) {
484
+ if (arr.length > this.maxMessagesPerChat) {
485
+ this.messages.set(jid, arr.slice(-this.maxMessagesPerChat));
486
+ }
487
+ }
488
+
489
+ return true;
490
+ }
491
+
492
+ // ── Analytics ───────────────────────────────────────────────────────────
493
+
494
+ getAnalyticsOverview(options = {}) {
495
+ const analytics = this._getAnalyticsCache();
496
+ const topChats = Math.min(options.top_chats || 10, 100);
497
+ const topTokens = Math.min(options.top_tokens || 20, 100);
498
+ const topSenders = Math.min(options.top_senders || 10, 100);
499
+ const days = Math.min(options.days || 30, 365);
500
+
501
+ return {
502
+ totals: analytics.totals,
503
+ indexed_chats: analytics.chatSummaries.length,
504
+ indexed_messages: analytics.totals.messages,
505
+ active_days: analytics.dailyActivity.length,
506
+ top_chats: analytics.chatSummaries.slice(0, topChats),
507
+ top_tokens: analytics.topTokens.slice(0, topTokens),
508
+ top_senders: analytics.topSenders.slice(0, topSenders),
509
+ message_types: analytics.messageTypes,
510
+ hourly_activity: analytics.hourlyActivity,
511
+ daily_activity: analytics.dailyActivity.slice(-days),
512
+ };
513
+ }
514
+
515
+ listAnalyticsTopChats(options = {}) {
516
+ const analytics = this._getAnalyticsCache();
517
+ const limit = Math.min(options.limit || 20, 200);
518
+ const sortBy = options.sort_by || "message_count";
519
+ const chats = [...analytics.chatSummaries];
520
+ const sorters = {
521
+ message_count: (a, b) => (b.content_message_count - a.content_message_count) || (b.message_count - a.message_count) || (b.last_activity || 0) - (a.last_activity || 0),
522
+ last_activity: (a, b) => (b.last_activity || 0) - (a.last_activity || 0),
523
+ active_days: (a, b) => (b.active_days - a.active_days) || (b.content_message_count - a.content_message_count) || (b.message_count - a.message_count),
524
+ participants: (a, b) => (b.participant_count - a.participant_count) || (b.content_message_count - a.content_message_count) || (b.message_count - a.message_count),
525
+ };
526
+ chats.sort(sorters[sortBy] || sorters.message_count);
527
+ return chats.slice(0, limit);
528
+ }
529
+
530
+ getChatAnalytics(jid, options = {}) {
531
+ const analytics = this._getAnalyticsCache();
532
+ const chat = analytics.chatByJid.get(jid);
533
+ if (!chat) return null;
534
+
535
+ const topTokens = Math.min(options.top_tokens || 15, 100);
536
+ const topSenders = Math.min(options.top_senders || 10, 100);
537
+ const timelineDays = Math.min(options.days || 30, 365);
538
+
539
+ return {
540
+ ...chat,
541
+ top_tokens: chat.top_tokens.slice(0, topTokens),
542
+ top_senders: chat.top_senders.slice(0, topSenders),
543
+ recent_messages: chat.recent_messages.slice(0, Math.min(options.recent_messages || 5, 20)),
544
+ daily_activity: chat.daily_activity.slice(-timelineDays),
545
+ };
546
+ }
547
+
548
+ getActivityTimeline(options = {}) {
549
+ const analytics = this._getAnalyticsCache();
550
+ const days = Math.min(options.days || 30, 365);
551
+
552
+ if (options.jid) {
553
+ const chat = analytics.chatByJid.get(options.jid);
554
+ if (!chat) return null;
555
+ return {
556
+ jid: options.jid,
557
+ days,
558
+ total_messages: chat.message_count,
559
+ buckets: chat.daily_activity.slice(-days),
560
+ };
561
+ }
562
+
563
+ return {
564
+ days,
565
+ total_messages: analytics.totals.messages,
566
+ buckets: analytics.dailyActivity.slice(-days),
567
+ };
568
+ }
569
+
570
+ analyticsSearch(query, jid, limit = 20, options = {}) {
571
+ const analytics = this._getAnalyticsCache();
572
+ const terms = this._tokenize(query);
573
+ const cappedLimit = Math.min(limit || 20, 200);
574
+ const { since, until } = options;
575
+
576
+ if (terms.length === 0) {
577
+ return [];
578
+ }
579
+
580
+ // Support single JID, array of JIDs, or null (all)
581
+ const jidSet = jid
582
+ ? new Set(Array.isArray(jid) ? jid : [jid])
583
+ : null;
584
+
585
+ const scores = new Map();
586
+ for (const term of terms) {
587
+ const refs = analytics.tokenIndex.get(term) || [];
588
+ for (const ref of refs) {
589
+ if (jidSet && !jidSet.has(ref.jid)) continue;
590
+ const existing = scores.get(ref.id) || {
591
+ jid: ref.jid,
592
+ id: ref.id,
593
+ matched_terms: new Set(),
594
+ score: 0,
595
+ };
596
+ existing.matched_terms.add(term);
597
+ existing.score += ref.weight;
598
+ scores.set(ref.id, existing);
599
+ }
600
+ }
601
+
602
+ const ranked = [];
603
+ for (const entry of scores.values()) {
604
+ const msg = this.getMessage(entry.id);
605
+ const formatted = formatMessage(msg);
606
+ if (!formatted) continue;
607
+ // Apply temporal filters
608
+ const ts = Number(formatted.timestamp || 0);
609
+ if (since != null && ts < since) continue;
610
+ if (until != null && ts > until) continue;
611
+ const text = formatted.text.toLowerCase();
612
+ const phraseBoost = text.includes(query.toLowerCase()) ? 2 : 0;
613
+ const timestampBoost = formatted.timestamp ? Number(formatted.timestamp) / 1e10 : 0;
614
+ ranked.push({
615
+ ...formatted,
616
+ score: Number((entry.score + phraseBoost + timestampBoost).toFixed(6)),
617
+ matched_terms: Array.from(entry.matched_terms).sort(),
618
+ });
619
+ }
620
+
621
+ ranked.sort((a, b) => (b.score - a.score) || ((b.timestamp || 0) - (a.timestamp || 0)));
622
+ return ranked.slice(0, cappedLimit);
623
+ }
624
+
625
+ // ── Bind to Baileys events ───────────────────────────────────────────────
626
+
627
+ /**
628
+ * Bind all relevant Baileys socket events to this store.
629
+ * @param {import("@whiskeysockets/baileys").WASocket} sock
630
+ */
631
+ bind(sock) {
632
+ sock.ev.on("messaging-history.set", (data) => this.handleHistorySync(data));
633
+ sock.ev.on("chats.upsert", (chats) => this.upsertChats(chats));
634
+ sock.ev.on("chats.update", (updates) => this.updateChats(updates));
635
+ sock.ev.on("chats.delete", (ids) => this.deleteChats(ids));
636
+ sock.ev.on("contacts.upsert", (contacts) => this.upsertContacts(contacts));
637
+ sock.ev.on("contacts.update", (updates) => this.updateContacts(updates));
638
+ sock.ev.on("messages.upsert", ({ messages }) => this.upsertMessages(messages));
639
+ sock.ev.on("messages.delete", (info) => {
640
+ if (info.keys) this.deleteMessages(info.keys);
641
+ });
642
+ sock.ev.on("groups.upsert", (groups) => {
643
+ for (const g of groups) this.setGroupMeta(g.id, g);
644
+ });
645
+ sock.ev.on("groups.update", (updates) => {
646
+ for (const u of updates) {
647
+ const existing = this.getGroupMeta(u.id) || {};
648
+ this.setGroupMeta(u.id, { ...existing, ...u });
649
+ }
650
+ });
651
+ }
652
+
653
+ // ── Stats ────────────────────────────────────────────────────────────────
654
+
655
+ stats() {
656
+ let totalMessages = 0;
657
+ for (const msgs of this.messages.values()) {
658
+ totalMessages += msgs.length;
659
+ }
660
+ return {
661
+ chats: this.chats.size,
662
+ contacts: this.contacts.size,
663
+ messages: totalMessages,
664
+ groups: this.groupMeta.size,
665
+ };
666
+ }
667
+
668
+ // ── Internal ─────────────────────────────────────────────────────────────
669
+
670
+ _applyMessageFilters(messages, options = {}) {
671
+ const { since, until, types, excludeTypes } = options;
672
+ let result = messages;
673
+
674
+ if (since != null) {
675
+ result = result.filter((m) => Number(m.messageTimestamp || 0) >= since);
676
+ }
677
+ if (until != null) {
678
+ result = result.filter((m) => Number(m.messageTimestamp || 0) <= until);
679
+ }
680
+ if (types && types.length > 0) {
681
+ const typeSet = new Set(types);
682
+ result = result.filter((m) => {
683
+ const formatted = formatMessage(m);
684
+ return formatted && typeSet.has(formatted.type);
685
+ });
686
+ }
687
+ if (excludeTypes && excludeTypes.length > 0) {
688
+ const excludeSet = new Set(excludeTypes);
689
+ result = result.filter((m) => {
690
+ const formatted = formatMessage(m);
691
+ return formatted && !excludeSet.has(formatted.type);
692
+ });
693
+ }
694
+ return result;
695
+ }
696
+
697
+ _trimChats() {
698
+ if (this.chats.size <= this.maxChats) return;
699
+ const sorted = Array.from(this.chats.entries()).sort(([, a], [, b]) => {
700
+ return Number(b.conversationTimestamp || 0) - Number(a.conversationTimestamp || 0);
701
+ });
702
+ const toRemove = sorted.slice(this.maxChats);
703
+ for (const [jid] of toRemove) {
704
+ this.chats.delete(jid);
705
+ }
706
+ }
707
+
708
+ _notifyChanged() {
709
+ this.analyticsCache = null;
710
+ if (this.onChange) {
711
+ this.onChange();
712
+ }
713
+ }
714
+
715
+ _touchChatFromMessage(msg) {
716
+ const jid = msg.key?.remoteJid;
717
+ if (!jid) return;
718
+
719
+ const existing = this.chats.get(jid) || { id: jid };
720
+ const formatted = formatMessage(msg);
721
+ const timestamp = msg.messageTimestamp
722
+ ? Number(msg.messageTimestamp)
723
+ : Number(existing.conversationTimestamp || 0);
724
+
725
+ this.chats.set(jid, {
726
+ ...existing,
727
+ id: jid,
728
+ conversationTimestamp: timestamp || existing.conversationTimestamp,
729
+ name:
730
+ existing.name ||
731
+ existing.subject ||
732
+ msg.pushName ||
733
+ formatted?.push_name ||
734
+ existing.name,
735
+ });
736
+
737
+ this._trimChats();
738
+ }
739
+
740
+ _getAnalyticsCache() {
741
+ if (this.analyticsCache) {
742
+ return this.analyticsCache;
743
+ }
744
+
745
+ const tokenIndex = new Map();
746
+ const globalTokenCounts = new Map();
747
+ const globalSenderCounts = new Map();
748
+ const globalTypeCounts = new Map();
749
+ const globalDailyActivity = new Map();
750
+ const hourlyActivity = Array.from({ length: 24 }, (_, hour) => ({ hour, count: 0 }));
751
+ const chatByJid = new Map();
752
+ const allChatIds = new Set([
753
+ ...this.chats.keys(),
754
+ ...this.messages.keys(),
755
+ ...this.groupMeta.keys(),
756
+ ]);
757
+
758
+ for (const jid of allChatIds) {
759
+ chatByJid.set(jid, this._createEmptyChatAnalytics(jid));
760
+ }
761
+
762
+ for (const [jid, msgs] of this.messages.entries()) {
763
+ const chat = chatByJid.get(jid) || this._createEmptyChatAnalytics(jid);
764
+ for (const msg of msgs) {
765
+ const formatted = formatMessage(msg);
766
+ if (!formatted) continue;
767
+
768
+ const timestamp = Number(formatted.timestamp || msg.messageTimestamp || 0) || 0;
769
+ chat.message_count += 1;
770
+ if (formatted.from_me) {
771
+ chat.from_me_count += 1;
772
+ } else {
773
+ chat.external_count += 1;
774
+ }
775
+
776
+ if (timestamp) {
777
+ chat.first_activity = chat.first_activity === null ? timestamp : Math.min(chat.first_activity, timestamp);
778
+ chat.last_activity = Math.max(chat.last_activity || 0, timestamp);
779
+ const dayKey = this._toDayKey(timestamp);
780
+ chat.daily_counts.set(dayKey, (chat.daily_counts.get(dayKey) || 0) + 1);
781
+ globalDailyActivity.set(dayKey, (globalDailyActivity.get(dayKey) || 0) + 1);
782
+ hourlyActivity[this._toHour(timestamp)].count += 1;
783
+ }
784
+
785
+ const type = formatted.type || "unknown";
786
+ chat.type_counts.set(type, (chat.type_counts.get(type) || 0) + 1);
787
+ globalTypeCounts.set(type, (globalTypeCounts.get(type) || 0) + 1);
788
+
789
+ const sender = this._getMessageSender(msg);
790
+ if (sender) {
791
+ chat.sender_counts.set(sender, (chat.sender_counts.get(sender) || 0) + 1);
792
+ globalSenderCounts.set(sender, (globalSenderCounts.get(sender) || 0) + 1);
793
+ }
794
+
795
+ const tokens = this._shouldIndexMessageText(formatted)
796
+ ? this._tokenize(formatted.text)
797
+ : [];
798
+ if (tokens.length > 0) {
799
+ chat.content_message_count += 1;
800
+ }
801
+ const uniqueTokens = new Set(tokens);
802
+ for (const token of tokens) {
803
+ chat.token_counts.set(token, (chat.token_counts.get(token) || 0) + 1);
804
+ globalTokenCounts.set(token, (globalTokenCounts.get(token) || 0) + 1);
805
+ }
806
+ for (const token of uniqueTokens) {
807
+ if (!tokenIndex.has(token)) tokenIndex.set(token, []);
808
+ tokenIndex.get(token).push({
809
+ jid,
810
+ id: formatted.id,
811
+ weight: chat.token_counts.get(token) || 1,
812
+ });
813
+ }
814
+ }
815
+ chatByJid.set(jid, chat);
816
+ }
817
+
818
+ for (const [jid, chat] of chatByJid.entries()) {
819
+ const rawChat = this.getChat(jid) || {};
820
+ const groupMeta = this.getGroupMeta(jid);
821
+ chat.name = rawChat.name || rawChat.subject || groupMeta?.subject || chat.name;
822
+ chat.is_group = isGroupJid(jid);
823
+ chat.participant_count = groupMeta?.participants?.length || 0;
824
+ chat.active_days = chat.daily_counts.size;
825
+ chat.last_activity = chat.last_activity || Number(rawChat.conversationTimestamp || groupMeta?.subjectTime || groupMeta?.creation || 0) || null;
826
+ chat.top_tokens = this._rankCountMap(chat.token_counts, 10);
827
+ chat.top_senders = this._rankCountMap(chat.sender_counts, 10, "jid");
828
+ chat.type_breakdown = this._rankCountMap(chat.type_counts, 10, "type");
829
+ chat.daily_activity = this._mapToSeries(chat.daily_counts, "date");
830
+ chat.recent_messages = this.getMessages(jid, 5).map((msg) => formatMessage(msg)).filter(Boolean);
831
+ delete chat.token_counts;
832
+ delete chat.sender_counts;
833
+ delete chat.type_counts;
834
+ delete chat.daily_counts;
835
+ }
836
+
837
+ const chatSummaries = Array.from(chatByJid.values()).sort((a, b) => {
838
+ return (b.content_message_count - a.content_message_count)
839
+ || (b.message_count - a.message_count)
840
+ || ((b.last_activity || 0) - (a.last_activity || 0));
841
+ });
842
+
843
+ this.analyticsCache = {
844
+ totals: this.stats(),
845
+ chatByJid,
846
+ chatSummaries,
847
+ topTokens: this._rankCountMap(globalTokenCounts, 25),
848
+ topSenders: this._rankCountMap(globalSenderCounts, 25, "jid"),
849
+ messageTypes: this._rankCountMap(globalTypeCounts, 25, "type"),
850
+ hourlyActivity,
851
+ dailyActivity: this._mapToSeries(globalDailyActivity, "date"),
852
+ tokenIndex,
853
+ };
854
+
855
+ return this.analyticsCache;
856
+ }
857
+
858
+ _createEmptyChatAnalytics(jid) {
859
+ const rawChat = this.getChat(jid) || {};
860
+ return {
861
+ jid,
862
+ name: rawChat.name || rawChat.subject || jid,
863
+ is_group: isGroupJid(jid),
864
+ participant_count: 0,
865
+ message_count: 0,
866
+ content_message_count: 0,
867
+ from_me_count: 0,
868
+ external_count: 0,
869
+ active_days: 0,
870
+ first_activity: null,
871
+ last_activity: null,
872
+ top_tokens: [],
873
+ top_senders: [],
874
+ type_breakdown: [],
875
+ daily_activity: [],
876
+ recent_messages: [],
877
+ token_counts: new Map(),
878
+ sender_counts: new Map(),
879
+ type_counts: new Map(),
880
+ daily_counts: new Map(),
881
+ };
882
+ }
883
+
884
+ _rankCountMap(map, limit, keyName = "token") {
885
+ return Array.from(map.entries())
886
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
887
+ .slice(0, limit)
888
+ .map(([key, count]) => ({ [keyName]: key, count }));
889
+ }
890
+
891
+ _mapToSeries(map, keyName) {
892
+ return Array.from(map.entries())
893
+ .sort((a, b) => a[0].localeCompare(b[0]))
894
+ .map(([key, count]) => ({ [keyName]: key, count }));
895
+ }
896
+
897
+ _getMessageSender(msg) {
898
+ if (msg?.key?.fromMe) return "me";
899
+ return msg?.key?.participant || msg?.key?.remoteJid || null;
900
+ }
901
+
902
+ _toDayKey(timestamp) {
903
+ return new Date(Number(timestamp) * 1000).toISOString().slice(0, 10);
904
+ }
905
+
906
+ _toHour(timestamp) {
907
+ return new Date(Number(timestamp) * 1000).getHours();
908
+ }
909
+
910
+ _tokenize(text) {
911
+ return String(text || "")
912
+ .toLowerCase()
913
+ .match(/[\p{L}\p{N}_-]+/gu)
914
+ ?.filter((token) => token.length >= 2 && !ANALYTICS_STOP_WORDS.has(token) && /\D/.test(token)) || [];
915
+ }
916
+
917
+ _shouldIndexMessageText(message) {
918
+ const text = String(message?.text || "").trim();
919
+ if (!text) return false;
920
+ if (/^\[[^\]]+\]$/.test(text)) return false;
921
+ return !["protocol", "unknown", "senderKeyDistribution"].includes(message?.type);
922
+ }
923
+ }
924
+
925
+ module.exports = Store;