miaw-core 1.13.1 → 1.13.2

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.
@@ -9,6 +9,7 @@ import { AuthHandler } from "../handlers/AuthHandler.js";
9
9
  import { MessageHandler } from "../handlers/MessageHandler.js";
10
10
  import { TIMEOUTS, THRESHOLDS } from "../constants/timeouts.js";
11
11
  import { CACHE_CONFIG } from "../constants/cache.js";
12
+ import { LAZY_HISTORY_SYNC_TYPES } from "../constants/history-sync.js";
12
13
  import { validatePhoneNumber, validateJID, validateMessageText, validateGroupName, validatePhoneNumbers, } from "../utils/validation.js";
13
14
  import { createProxyAgents, maskProxyUrl, validateProxyConfig, } from "../utils/proxy-agent.js";
14
15
  /**
@@ -172,6 +173,20 @@ export class MiawClient extends EventEmitter {
172
173
  contactsStore = new Map();
173
174
  chatsStore = new Map();
174
175
  messagesStore = new Map();
176
+ // Message ids per chat, so storeMessage() dedups in O(1) instead of scanning
177
+ // the bucket. Kept in step with every messagesStore mutation.
178
+ messageIdIndex = new Map();
179
+ // Coalesced disk persistence: stores are marked dirty and written together
180
+ // after a quiet period (see schedulePersist).
181
+ dirtyStores = new Set();
182
+ persistTimer = null;
183
+ persistFirstDirtyAt = 0;
184
+ // Set once the session files are wiped (logout / unlink), so a late store
185
+ // update cannot recreate the deleted session dir. Cleared by connect().
186
+ persistSuspended = false;
187
+ // syncFullHistory as captured by the current connect(). enableSync() /
188
+ // disableSync() take effect on the next connection, not mid-sync.
189
+ activeSyncFullHistory = false;
175
190
  labelsStore = new Map();
176
191
  // Store label-chat associations (labelId -> Set of chatJids)
177
192
  labelChatsStore = new Map();
@@ -201,7 +216,10 @@ export class MiawClient extends EventEmitter {
201
216
  qrGracePeriod: options.qrGracePeriod || TIMEOUTS.QR_GRACE_PERIOD,
202
217
  qrScanTimeout: options.qrScanTimeout || TIMEOUTS.QR_SCAN_TIMEOUT,
203
218
  connectionTimeout: options.connectionTimeout || TIMEOUTS.CONNECTION_TIMEOUT,
204
- syncFullHistory: options.syncFullHistory !== false,
219
+ // Default OFF: a fresh pair would otherwise pull the phone's entire
220
+ // history up front. Only the bootstrap is ingested; older history is
221
+ // fetched on demand via loadMoreMessages().
222
+ syncFullHistory: options.syncFullHistory === true,
205
223
  // Default OFF. The whole point of the existing gate is that a bot must
206
224
  // not react to its own sends, so opting in has to be deliberate.
207
225
  emitOwnMessages: options.emitOwnMessages === true,
@@ -246,6 +264,10 @@ export class MiawClient extends EventEmitter {
246
264
  // Load persisted stores from disk before connecting
247
265
  // This ensures data is available even if Baileys history sync doesn't fire
248
266
  // (Known Baileys v7 issue: history sync notification may be received but never processed)
267
+ // Flush first: on a reconnect, writes still in the debounce window are
268
+ // newer than the files and would otherwise be replaced by them.
269
+ this.flushPersist();
270
+ this.persistSuspended = false;
249
271
  this.loadLabelsFromFile();
250
272
  this.loadContactsFromFile();
251
273
  this.loadLidMappingsFromFile(); // Load LID mappings BEFORE chats for resolution
@@ -280,6 +302,7 @@ export class MiawClient extends EventEmitter {
280
302
  this.activeProxy = usingCustomAgent ? undefined : this.describeConfiguredProxy();
281
303
  this.activeProxyKey = usingCustomAgent ? undefined : this.configuredProxyUrl();
282
304
  // Create socket
305
+ this.activeSyncFullHistory = this.options.syncFullHistory;
283
306
  const debugMode = this.options.debug;
284
307
  const logger = this.logger;
285
308
  this.socket = makeWASocket({
@@ -299,15 +322,15 @@ export class MiawClient extends EventEmitter {
299
322
  // Auto-recreate signal sessions on retry and migrate PN<->LID sessions
300
323
  // (Baileys rc13 default; set explicitly to document the LID reliance).
301
324
  enableAutoSessionRecreation: true,
302
- // Enable full history sync to populate stores (controlled by syncFullHistory option)
303
- syncFullHistory: this.options.syncFullHistory,
325
+ // Ask the phone for its full history only when opted in
326
+ syncFullHistory: this.activeSyncFullHistory,
304
327
  fireInitQueries: true,
305
- // Debug callback to see if history sync notifications are received
306
328
  shouldSyncHistoryMessage: (msg) => {
329
+ const accept = this.shouldProcessHistorySync(msg.syncType);
307
330
  if (debugMode) {
308
- logger.debug(`[shouldSyncHistoryMessage] syncType: ${msg.syncType}`);
331
+ logger.debug(`[shouldSyncHistoryMessage] syncType: ${msg.syncType} -> ${accept ? "accept" : "skip"}`);
309
332
  }
310
- return this.options.syncFullHistory;
333
+ return accept;
311
334
  },
312
335
  // Proxy support: agent for WebSocket, fetchAgent for media HTTP requests
313
336
  ...(proxyAgents?.wsAgent ? { agent: proxyAgents.wsAgent } : {}),
@@ -694,9 +717,10 @@ export class MiawClient extends EventEmitter {
694
717
  this.updateChatsStore(chats);
695
718
  });
696
719
  // History sync - populates contacts, chats, and messages from history
697
- this.socket.ev.on("messaging-history.set", ({ chats, contacts, messages, lidPnMappings, isLatest, peerDataRequestSessionId }) => {
720
+ this.socket.ev.on("messaging-history.set", ({ chats, contacts, messages, lidPnMappings, isLatest, peerDataRequestSessionId, syncType }) => {
698
721
  if (this.options.debug) {
699
722
  this.logger.debug("\n========== MESSAGING HISTORY SYNC ==========");
723
+ this.logger.debug(`Sync type: ${syncType ?? "unknown"}`);
700
724
  this.logger.debug(`Contacts: ${contacts.length}, Chats: ${chats.length}, Messages: ${messages.length}, LID maps: ${lidPnMappings?.length ?? 0}`);
701
725
  this.logger.debug(`Is Latest: ${isLatest}, SessionId: ${peerDataRequestSessionId || 'none'}`);
702
726
  this.logger.debug("============================================\n");
@@ -916,9 +940,9 @@ export class MiawClient extends EventEmitter {
916
940
  };
917
941
  this.labelsStore.set(label.id, labelData);
918
942
  }
919
- // Persist labels to disk after every update
943
+ // Persist labels to disk
920
944
  // This ensures labels survive reconnections where resyncAppState returns only patches
921
- this.saveLabelsToFile();
945
+ this.schedulePersist("labels");
922
946
  }
923
947
  });
924
948
  // Handle label-chat associations
@@ -1053,7 +1077,7 @@ export class MiawClient extends EventEmitter {
1053
1077
  }
1054
1078
  // Persist to disk if this is a new mapping
1055
1079
  if (isNew) {
1056
- this.saveLidMappingsToFile();
1080
+ this.schedulePersist("lid");
1057
1081
  }
1058
1082
  }
1059
1083
  /**
@@ -1366,10 +1390,10 @@ export class MiawClient extends EventEmitter {
1366
1390
  this.contactsStore.set(contact.id, contactInfo);
1367
1391
  }
1368
1392
  }
1369
- // Persist contacts to disk after every update
1393
+ // Persist contacts to disk
1370
1394
  // This ensures contacts survive reconnections where history sync may not fire
1371
1395
  if (contacts.length > 0) {
1372
- this.saveContactsToFile();
1396
+ this.schedulePersist("contacts");
1373
1397
  }
1374
1398
  }
1375
1399
  /**
@@ -1417,10 +1441,10 @@ export class MiawClient extends EventEmitter {
1417
1441
  this.chatsStore.set(originalId, chatInfo);
1418
1442
  }
1419
1443
  }
1420
- // Persist chats to disk after every update
1444
+ // Persist chats to disk
1421
1445
  // This ensures chats survive reconnections where history sync may not fire
1422
1446
  if (chats.length > 0) {
1423
- this.saveChatsToFile();
1447
+ this.schedulePersist("chats");
1424
1448
  }
1425
1449
  }
1426
1450
  /**
@@ -1455,6 +1479,26 @@ export class MiawClient extends EventEmitter {
1455
1479
  * @returns true if session was cleared, false if no session existed
1456
1480
  */
1457
1481
  clearSession() {
1482
+ return this.wipeSessionFiles();
1483
+ }
1484
+ /**
1485
+ * Delete the session files and everything cached for the account.
1486
+ *
1487
+ * Pending store writes are dropped and further ones refused until the next
1488
+ * connect(), or a late timer would recreate the deleted dir. The in-memory
1489
+ * stores are emptied too, so the old account's chats and messages are not
1490
+ * written into the next pairing's session.
1491
+ */
1492
+ wipeSessionFiles() {
1493
+ this.cancelPersist();
1494
+ this.persistSuspended = true;
1495
+ this.contactsStore.clear();
1496
+ this.chatsStore.clear();
1497
+ this.messagesStore.clear();
1498
+ this.messageIdIndex.clear();
1499
+ this.labelsStore.clear();
1500
+ this.labelChatsStore.clear();
1501
+ this.lidToJidMap.clear();
1458
1502
  return this.authHandler.clearSession();
1459
1503
  }
1460
1504
  /**
@@ -1471,6 +1515,8 @@ export class MiawClient extends EventEmitter {
1471
1515
  }
1472
1516
  // Reset logout flag
1473
1517
  this.loggingOut = false;
1518
+ // Write anything still pending before the stores are dropped
1519
+ this.flushPersist();
1474
1520
  // Clear LID cache
1475
1521
  this.lidToJidMap.clear();
1476
1522
  // Remove all event listeners
@@ -1519,7 +1565,7 @@ export class MiawClient extends EventEmitter {
1519
1565
  // Don't reconnect if logged out - clear session for fresh QR code on next connect
1520
1566
  if (statusCode === DisconnectReason.loggedOut) {
1521
1567
  this.logger.info("Logged out, clearing session for fresh authentication");
1522
- this.authHandler.clearSession();
1568
+ this.wipeSessionFiles();
1523
1569
  return false;
1524
1570
  }
1525
1571
  // Don't auto-reconnect when replaced by another connection: reconnecting
@@ -2932,6 +2978,90 @@ export class MiawClient extends EventEmitter {
2932
2978
  };
2933
2979
  }
2934
2980
  // =============================================================================
2981
+ // History Sync Policy & Coalesced Persistence
2982
+ // =============================================================================
2983
+ /**
2984
+ * Whether a history-sync notification of this type should be processed.
2985
+ *
2986
+ * Full mode accepts everything. Lazy mode (the default) accepts only the
2987
+ * bootstrap, small metadata payloads and ON_DEMAND replies, and skips the
2988
+ * bulk RECENT / FULL / status payloads. Never reject every type: ON_DEMAND
2989
+ * is what loadMoreMessages() waits for, and Baileys gets LID mappings only
2990
+ * through history sync.
2991
+ */
2992
+ shouldProcessHistorySync(syncType) {
2993
+ if (this.activeSyncFullHistory)
2994
+ return true;
2995
+ return syncType != null && LAZY_HISTORY_SYNC_TYPES.has(syncType);
2996
+ }
2997
+ /**
2998
+ * Mark a store dirty and (re)arm the write timer.
2999
+ *
3000
+ * Writes are trailing-debounced so a burst - e.g. a history batch of
3001
+ * thousands of messages - costs one write per store instead of one full-file
3002
+ * rewrite per item. PERSIST_MAX_WAIT bounds the delay under sustained
3003
+ * activity so a chatty session still persists.
3004
+ */
3005
+ schedulePersist(store) {
3006
+ if (this.persistSuspended)
3007
+ return;
3008
+ this.dirtyStores.add(store);
3009
+ const now = Date.now();
3010
+ if (this.persistFirstDirtyAt === 0) {
3011
+ this.persistFirstDirtyAt = now;
3012
+ }
3013
+ const remaining = TIMEOUTS.PERSIST_MAX_WAIT - (now - this.persistFirstDirtyAt);
3014
+ const delay = Math.max(0, Math.min(TIMEOUTS.PERSIST_DEBOUNCE, remaining));
3015
+ if (this.persistTimer) {
3016
+ clearTimeout(this.persistTimer);
3017
+ }
3018
+ this.persistTimer = setTimeout(() => this.flushPersist(), delay);
3019
+ // Never hold the process open just to write a cache.
3020
+ this.persistTimer.unref?.();
3021
+ }
3022
+ /** Write every dirty store now. */
3023
+ flushPersist() {
3024
+ const dirty = [...this.dirtyStores];
3025
+ this.cancelPersist();
3026
+ for (const store of dirty) {
3027
+ switch (store) {
3028
+ case "messages":
3029
+ this.saveMessagesToFile();
3030
+ break;
3031
+ case "chats":
3032
+ this.saveChatsToFile();
3033
+ break;
3034
+ case "contacts":
3035
+ this.saveContactsToFile();
3036
+ break;
3037
+ case "lid":
3038
+ this.saveLidMappingsToFile();
3039
+ break;
3040
+ case "labels":
3041
+ this.saveLabelsToFile();
3042
+ break;
3043
+ }
3044
+ }
3045
+ }
3046
+ /** Drop pending writes without writing them. */
3047
+ cancelPersist() {
3048
+ if (this.persistTimer) {
3049
+ clearTimeout(this.persistTimer);
3050
+ this.persistTimer = null;
3051
+ }
3052
+ this.dirtyStores.clear();
3053
+ this.persistFirstDirtyAt = 0;
3054
+ }
3055
+ /**
3056
+ * Write JSON via a temp file + rename, so a crash mid-write never leaves a
3057
+ * truncated store behind. Compact output: these are caches, not configs.
3058
+ */
3059
+ writeJsonFileAtomic(filePath, data) {
3060
+ const tmpPath = `${filePath}.tmp`;
3061
+ fs.writeFileSync(tmpPath, JSON.stringify(data), "utf8");
3062
+ fs.renameSync(tmpPath, filePath);
3063
+ }
3064
+ // =============================================================================
2935
3065
  // Persistent Label Storage
2936
3066
  // =============================================================================
2937
3067
  /**
@@ -2955,7 +3085,7 @@ export class MiawClient extends EventEmitter {
2955
3085
  }
2956
3086
  // Convert Map to array for JSON serialization
2957
3087
  const labelsData = Array.from(this.labelsStore.values());
2958
- fs.writeFileSync(labelsPath, JSON.stringify(labelsData, null, 2), "utf8");
3088
+ this.writeJsonFileAtomic(labelsPath, labelsData);
2959
3089
  this.logger.debug(`Saved ${labelsData.length} labels to ${labelsPath}`);
2960
3090
  }
2961
3091
  catch (error) {
@@ -3023,7 +3153,7 @@ export class MiawClient extends EventEmitter {
3023
3153
  }
3024
3154
  }
3025
3155
  const contactsData = Array.from(uniqueContacts.values());
3026
- fs.writeFileSync(contactsPath, JSON.stringify(contactsData, null, 2), "utf8");
3156
+ this.writeJsonFileAtomic(contactsPath, contactsData);
3027
3157
  this.logger.debug(`Saved ${contactsData.length} contacts to ${contactsPath}`);
3028
3158
  }
3029
3159
  catch (error) {
@@ -3079,7 +3209,7 @@ export class MiawClient extends EventEmitter {
3079
3209
  const mappings = this.getLidMappings();
3080
3210
  const count = Object.keys(mappings).length;
3081
3211
  if (count > 0) {
3082
- fs.writeFileSync(mappingsPath, JSON.stringify(mappings, null, 2), "utf8");
3212
+ this.writeJsonFileAtomic(mappingsPath, mappings);
3083
3213
  this.logger.debug(`Saved ${count} LID mappings to ${mappingsPath}`);
3084
3214
  }
3085
3215
  }
@@ -3141,7 +3271,7 @@ export class MiawClient extends EventEmitter {
3141
3271
  }
3142
3272
  }
3143
3273
  const chatsData = Array.from(uniqueChats.values());
3144
- fs.writeFileSync(chatsPath, JSON.stringify(chatsData, null, 2), "utf8");
3274
+ this.writeJsonFileAtomic(chatsPath, chatsData);
3145
3275
  this.logger.debug(`Saved ${chatsData.length} chats to ${chatsPath}`);
3146
3276
  }
3147
3277
  catch (error) {
@@ -3194,18 +3324,26 @@ export class MiawClient extends EventEmitter {
3194
3324
  */
3195
3325
  storeMessage(normalized) {
3196
3326
  const chatJid = normalized.from;
3197
- const bucket = this.messagesStore.get(chatJid);
3198
- if (bucket) {
3199
- // Dedup by id. An empty id (missing key.id) can't be deduped -> append.
3200
- if (normalized.id && bucket.some((m) => m.id === normalized.id)) {
3327
+ let ids = this.messageIdIndex.get(chatJid);
3328
+ if (!ids) {
3329
+ ids = new Set();
3330
+ this.messageIdIndex.set(chatJid, ids);
3331
+ }
3332
+ // Dedup by id. An empty id (missing key.id) can't be deduped -> append.
3333
+ if (normalized.id) {
3334
+ if (ids.has(normalized.id)) {
3201
3335
  return false;
3202
3336
  }
3337
+ ids.add(normalized.id);
3338
+ }
3339
+ const bucket = this.messagesStore.get(chatJid);
3340
+ if (bucket) {
3203
3341
  bucket.push(normalized);
3204
3342
  }
3205
3343
  else {
3206
3344
  this.messagesStore.set(chatJid, [normalized]);
3207
3345
  }
3208
- this.saveMessagesToFile();
3346
+ this.schedulePersist("messages");
3209
3347
  return true;
3210
3348
  }
3211
3349
  /**
@@ -3228,7 +3366,7 @@ export class MiawClient extends EventEmitter {
3228
3366
  }
3229
3367
  // Count total messages
3230
3368
  const totalMessages = Array.from(this.messagesStore.values()).reduce((sum, msgs) => sum + msgs.length, 0);
3231
- fs.writeFileSync(messagesPath, JSON.stringify(messagesData, null, 2), "utf8");
3369
+ this.writeJsonFileAtomic(messagesPath, messagesData);
3232
3370
  this.logger.debug(`Saved ${totalMessages} messages across ${this.messagesStore.size} chats to ${messagesPath}`);
3233
3371
  }
3234
3372
  catch (error) {
@@ -3250,10 +3388,12 @@ export class MiawClient extends EventEmitter {
3250
3388
  if (messagesData && typeof messagesData === "object") {
3251
3389
  // Clear existing store and populate from file
3252
3390
  this.messagesStore.clear();
3391
+ this.messageIdIndex.clear();
3253
3392
  let totalMessages = 0;
3254
3393
  for (const [jid, messages] of Object.entries(messagesData)) {
3255
3394
  if (Array.isArray(messages)) {
3256
3395
  this.messagesStore.set(jid, messages);
3396
+ this.messageIdIndex.set(jid, new Set(messages.map((m) => m.id).filter(Boolean)));
3257
3397
  totalMessages += messages.length;
3258
3398
  }
3259
3399
  }
@@ -6548,6 +6688,8 @@ export class MiawClient extends EventEmitter {
6548
6688
  this.activeProxyKey = undefined;
6549
6689
  // Cleanup console filter if it was enabled (reference counted)
6550
6690
  this.releaseConsoleFilter();
6691
+ // Session is preserved, so pending store writes must land
6692
+ this.flushPersist();
6551
6693
  this.updateConnectionState("disconnected");
6552
6694
  this.logger.info("Disconnected (session preserved)");
6553
6695
  // The `disconnected` event previously fired only from handleDisconnect(),
@@ -6656,8 +6798,8 @@ export class MiawClient extends EventEmitter {
6656
6798
  this.socket.end(undefined);
6657
6799
  this.socket = null;
6658
6800
  }
6659
- // Clear session files
6660
- this.authHandler.clearSession();
6801
+ // Clear session files (and drop pending store writes with them)
6802
+ this.wipeSessionFiles();
6661
6803
  // Reset logout flag
6662
6804
  this.loggingOut = false;
6663
6805
  // Cleanup console filter if it was enabled (reference counted)
@@ -6762,21 +6904,24 @@ export class MiawClient extends EventEmitter {
6762
6904
  }
6763
6905
  }
6764
6906
  /**
6765
- * Enable history sync for next connection
6907
+ * Request the phone's full message history on the next connection.
6908
+ * Only a fresh pairing receives it; an existing session is not re-synced.
6766
6909
  */
6767
6910
  enableSync() {
6768
6911
  this.options.syncFullHistory = true;
6769
- this.logger.info("History sync enabled (will take effect on next connect)");
6912
+ this.logger.info("Full history sync enabled (will take effect on next connect)");
6770
6913
  }
6771
6914
  /**
6772
- * Disable history sync for next connection
6915
+ * Ingest only the bootstrap on the next connection (the default). The
6916
+ * bootstrap and on-demand replies are still processed, so loadMoreMessages()
6917
+ * keeps working.
6773
6918
  */
6774
6919
  disableSync() {
6775
6920
  this.options.syncFullHistory = false;
6776
- this.logger.info("History sync disabled (will take effect on next connect)");
6921
+ this.logger.info("Full history sync disabled (bootstrap + on-demand still active; takes effect on next connect)");
6777
6922
  }
6778
6923
  /**
6779
- * Check if history sync is enabled
6924
+ * Check if full history sync is enabled
6780
6925
  */
6781
6926
  isSyncEnabled() {
6782
6927
  return this.options.syncFullHistory;