@pontalabs/baileys 1.0.2 → 1.0.4

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.
@@ -360,11 +360,17 @@ const makeMessagesSocket = (config) => {
360
360
  if (force) {
361
361
  jidsRequiringFetch = jids;
362
362
  } else {
363
- const addrs = jids.map(jid => (signalRepository.jidToSignalProtocolAddress(jid)));
364
- const sessions = await authState.keys.get('session', addrs);
363
+ // FIX: sebelumnya cuma ngecek "ada record tersimpan atau enggak"
364
+ // (truthy check di authState.keys.get('session', ...)). Record
365
+ // yang kosong/stale/rusak (misal device grup yang session-nya
366
+ // kebentuk tapi gak pernah kepakai/keisi ratchet) tetep truthy,
367
+ // jadi dianggap "udah punya sesi" padahal libsignal-nya beneran
368
+ // kosong -> pas encrypt muncul "SessionError: No sessions".
369
+ // validateSession ngecek session-nya beneran ada & "open" enggak,
370
+ // bukan cuma record-nya ada.
365
371
  for (const jid of jids) {
366
- const signalId = signalRepository.jidToSignalProtocolAddress(jid);
367
- if (!sessions[signalId]) {
372
+ const sessionValidation = await signalRepository.validateSession(jid);
373
+ if (!sessionValidation.exists) {
368
374
  jidsRequiringFetch.push(jid);
369
375
  }
370
376
  }
@@ -197,6 +197,12 @@ const makeSocket = (config) => {
197
197
  let lastDateRecv
198
198
  let epoch = 1
199
199
  let keepAliveReq
200
+ // FIX: sebelumnya uploadPreKeysToServerIfRequired() cuma dipanggil SEKALI pas
201
+ // 'CB:success' (baru connect/reconnect). Kalau proses bot jalan lama (berhari-hari)
202
+ // tanpa reconnect, prekey yang kepakai server gak pernah dicek ulang sampai jumlahnya
203
+ // abis - baru ketahuan pas sudah kejadian error decrypt di penerima. Interval ini
204
+ // bikin prekey selalu "terupdate", dicek ulang berkala tanpa perlu nunggu reconnect.
205
+ let preKeyCheckInterval
200
206
  let qrTimer
201
207
  let closed = false
202
208
 
@@ -516,6 +522,7 @@ const makeSocket = (config) => {
516
522
  trace: error?.stack
517
523
  }, error ? 'connection errored' : 'connection closed')
518
524
  clearInterval(keepAliveReq)
525
+ clearInterval(preKeyCheckInterval)
519
526
  clearTimeout(qrTimer)
520
527
  ws.removeAllListeners('close')
521
528
  ws.removeAllListeners('open')
@@ -822,6 +829,16 @@ const makeSocket = (config) => {
822
829
  ws.on('CB:success', async (node) => {
823
830
  try {
824
831
  await uploadPreKeysToServerIfRequired()
832
+ // Jalankan ulang pengecekan prekey tiap 6 jam selama koneksi hidup, bukan
833
+ // cuma sekali pas connect. uploadPreKeysToServerIfRequired() sendiri sudah
834
+ // pintar (cuma benar-benar upload kalau count di server rendah/ada yang
835
+ // hilang), jadi interval ini murah dan aman dijalankan berkala.
836
+ clearInterval(preKeyCheckInterval)
837
+ preKeyCheckInterval = setInterval(() => {
838
+ uploadPreKeysToServerIfRequired().catch((err) => {
839
+ logger.warn({ err }, 'periodic pre-key check failed')
840
+ })
841
+ }, 6 * 60 * 60 * 1000)
825
842
  await sendPassiveIq('active')
826
843
  } catch (err) {
827
844
  logger.warn({
@@ -81,92 +81,96 @@ function makeCacheableSignalKeyStore(store, logger, _cache) {
81
81
  }
82
82
  }
83
83
 
84
- // Module-level specialized mutexes for pre-key operations
85
- const preKeyMutex = new mutex_1.Mutex()
86
- const signedPreKeyMutex = new mutex_1.Mutex()
87
-
88
- /**
89
- * Get the appropriate mutex for the key type
90
- */
91
- const getPreKeyMutex = (keyType) => {
92
- return keyType === 'signed-pre-key' ? signedPreKeyMutex : preKeyMutex
93
- }
94
-
95
84
  /**
96
- * Handles pre-key operations with mutex protection
85
+ * Handles pre-key operations.
86
+ *
87
+ * IMPORTANT: locking for this function is provided by the CALLER via the
88
+ * session-scoped `getKeyTypeMutex(keyType)` mutex (the same one used by
89
+ * get()/set() for that key type). We intentionally do NOT acquire our own
90
+ * mutex here anymore.
91
+ *
92
+ * Previously this used module-level singleton mutexes (`preKeyMutex`,
93
+ * `signedPreKeyMutex`) declared OUTSIDE `addTransactionCapability`. Because
94
+ * those were created once per process (not once per auth state / socket),
95
+ * every session running in the same Node process shared the exact same
96
+ * lock. That caused two problems:
97
+ * 1. Unrelated sessions serialized on each other's pre-key writes
98
+ * (needless contention/latency when running multiple accounts).
99
+ * 2. The lock domain didn't match the one used by get()/withMutexes()
100
+ * (`getKeyTypeMutex`), so reads/writes and deletion-validation could
101
+ * interleave without real mutual exclusion (TOCTOU race on the
102
+ * "does this key still exist" check), which is what let pre-key /
103
+ * signed-pre-key state get corrupted under concurrent access.
97
104
  */
98
105
  async function handlePreKeyOperations(data, keyType, transactionCache, mutations, logger, isInTransaction, state) {
99
- const mutex = getPreKeyMutex(keyType)
100
- await mutex.runExclusive(async () => {
101
- const keyData = data[keyType]
102
- if (!keyData)
103
- return
104
-
105
- // Ensure structures exist
106
- transactionCache[keyType] = transactionCache[keyType] || {}
107
- mutations[keyType] = mutations[keyType] || {}
108
-
109
- // Separate deletions from updates for batch processing
110
- const deletionKeys = []
111
- const updateKeys = []
112
-
113
- for (const keyId in keyData) {
114
- if (keyData[keyId] === null) {
115
- deletionKeys.push(keyId)
116
- }
117
- else {
118
- updateKeys.push(keyId)
119
- }
106
+ const keyData = data[keyType]
107
+ if (!keyData)
108
+ return
109
+
110
+ // Ensure structures exist
111
+ transactionCache[keyType] = transactionCache[keyType] || {}
112
+ mutations[keyType] = mutations[keyType] || {}
113
+
114
+ // Separate deletions from updates for batch processing
115
+ const deletionKeys = []
116
+ const updateKeys = []
117
+
118
+ for (const keyId in keyData) {
119
+ if (keyData[keyId] === null) {
120
+ deletionKeys.push(keyId)
120
121
  }
121
-
122
- // Process updates first (no validation needed)
123
- for (const keyId of updateKeys) {
124
- if (transactionCache[keyType]) {
125
- transactionCache[keyType][keyId] = keyData[keyId]
126
- }
127
- if (mutations[keyType]) {
128
- mutations[keyType][keyId] = keyData[keyId]
129
- }
122
+ else {
123
+ updateKeys.push(keyId)
130
124
  }
131
-
132
- // Process deletions with validation
133
- if (deletionKeys.length === 0)
134
- return
135
-
136
- if (isInTransaction) {
137
- // In transaction, only allow deletion if key exists in cache
138
- for (const keyId of deletionKeys) {
139
- if (transactionCache[keyType]) {
140
- transactionCache[keyType][keyId] = null
141
- if (mutations[keyType]) {
142
- // Mark for deletion in mutations
143
- mutations[keyType][keyId] = null
144
- }
145
- }
146
- else {
147
- logger.warn(`Skipping deletion of non-existent ${keyType} in transaction: ${keyId}`)
148
- }
149
- }
150
- return
125
+ }
126
+
127
+ // Process updates first (no validation needed)
128
+ for (const keyId of updateKeys) {
129
+ if (transactionCache[keyType]) {
130
+ transactionCache[keyType][keyId] = keyData[keyId]
151
131
  }
152
-
153
- // Outside transaction, batch validate all deletions
154
- if (!state)
155
- return
156
-
157
- const existingKeys = await state.get(keyType, deletionKeys)
132
+ if (mutations[keyType]) {
133
+ mutations[keyType][keyId] = keyData[keyId]
134
+ }
135
+ }
136
+
137
+ // Process deletions with validation
138
+ if (deletionKeys.length === 0)
139
+ return
140
+
141
+ if (isInTransaction) {
142
+ // In transaction, only allow deletion if key exists in cache
158
143
  for (const keyId of deletionKeys) {
159
- if (existingKeys[keyId]) {
160
- if (transactionCache[keyType])
161
- transactionCache[keyType][keyId] = null
162
- if (mutations[keyType])
144
+ if (transactionCache[keyType]) {
145
+ transactionCache[keyType][keyId] = null
146
+ if (mutations[keyType]) {
147
+ // Mark for deletion in mutations
163
148
  mutations[keyType][keyId] = null
149
+ }
164
150
  }
165
151
  else {
166
- logger.warn(`Skipping deletion of non-existent ${keyType}: ${keyId}`)
152
+ logger.warn(`Skipping deletion of non-existent ${keyType} in transaction: ${keyId}`)
167
153
  }
168
154
  }
169
- })
155
+ return
156
+ }
157
+
158
+ // Outside transaction, batch validate all deletions
159
+ if (!state)
160
+ return
161
+
162
+ const existingKeys = await state.get(keyType, deletionKeys)
163
+ for (const keyId of deletionKeys) {
164
+ if (existingKeys[keyId]) {
165
+ if (transactionCache[keyType])
166
+ transactionCache[keyType][keyId] = null
167
+ if (mutations[keyType])
168
+ mutations[keyType][keyId] = null
169
+ }
170
+ else {
171
+ logger.warn(`Skipping deletion of non-existent ${keyType}: ${keyId}`)
172
+ }
173
+ }
170
174
  }
171
175
 
172
176
  /**
@@ -179,27 +183,34 @@ function handleNormalKeyOperations(data, key, transactionCache, mutations) {
179
183
  }
180
184
 
181
185
  /**
182
- * Process pre-key deletions with validation
186
+ * Process pre-key / signed-pre-key deletions with validation.
187
+ *
188
+ * NOTE: this is only ever called from inside a `withMutexes([..., keyType, ...])`
189
+ * block in `set()` below, which already holds the session-scoped
190
+ * `getKeyTypeMutex(keyType)` lock for the duration of the call. It must NOT
191
+ * acquire its own mutex here - `async-mutex`'s Mutex is not reentrant, so
192
+ * doing so would either deadlock (if it tried to re-acquire the same lock)
193
+ * or - as in the previous version - silently acquire a *different*, global
194
+ * lock that gave no real protection at all.
183
195
  */
184
196
  async function processPreKeyDeletions(data, keyType, state, logger) {
185
- const mutex = getPreKeyMutex(keyType)
186
- await mutex.runExclusive(async () => {
187
- const keyData = data[keyType]
188
- if (!keyData)
189
- return
190
-
191
- // Validate deletions
192
- for (const keyId in keyData) {
193
- if (keyData[keyId] === null) {
194
- const existingKeys = await state.get(keyType, [keyId])
195
- if (!existingKeys[keyId]) {
196
- logger.warn(`Skipping deletion of non-existent ${keyType}: ${keyId}`)
197
- if (data[keyType])
198
- delete data[keyType][keyId]
199
- }
200
- }
197
+ const keyData = data[keyType]
198
+ if (!keyData)
199
+ return
200
+
201
+ // Validate deletions
202
+ const deletionIds = Object.keys(keyData).filter(id => keyData[id] === null)
203
+ if (deletionIds.length === 0)
204
+ return
205
+
206
+ const existingKeys = await state.get(keyType, deletionIds)
207
+ for (const keyId of deletionIds) {
208
+ if (!existingKeys[keyId]) {
209
+ logger.warn(`Skipping deletion of non-existent ${keyType}: ${keyId}`)
210
+ if (data[keyType])
211
+ delete data[keyType][keyId]
201
212
  }
202
- })
213
+ }
203
214
  }
204
215
 
205
216
  /**
@@ -403,9 +414,13 @@ const addTransactionCapability = (state, logger, { maxCommitRetries, delayBetwee
403
414
  for (const key_ in data) {
404
415
  const key = key_
405
416
  transactionCache[key] = transactionCache[key] || {}
406
- // Special handling for pre-keys and signed-pre-keys
417
+ // Special handling for pre-keys and signed-pre-keys.
418
+ // Locked via the session-scoped getKeyTypeMutex(key) so this can't
419
+ // interleave with a concurrent non-transactional get()/set() for the
420
+ // same key type on this SAME auth state - no cross-session locking,
421
+ // no lock-domain mismatch.
407
422
  if (key === 'pre-key' || key === 'signed-pre-key') {
408
- await handlePreKeyOperations(data, key, transactionCache, mutations, logger, true)
423
+ await getKeyTypeMutex(key).runExclusive(() => handlePreKeyOperations(data, key, transactionCache, mutations, logger, true))
409
424
  }
410
425
  else {
411
426
  // Normal handling for other key types
@@ -442,7 +457,7 @@ const addTransactionCapability = (state, logger, { maxCommitRetries, delayBetwee
442
457
  // Process pre-keys and signed-pre-keys separately with specialized mutexes
443
458
  for (const key_ in nonSenderKeyData) {
444
459
  const keyType = key_
445
- if (keyType === 'pre-key') {
460
+ if (keyType === 'pre-key' || keyType === 'signed-pre-key') {
446
461
  await processPreKeyDeletions(nonSenderKeyData, keyType, state, logger)
447
462
  }
448
463
  }
@@ -457,7 +472,7 @@ const addTransactionCapability = (state, logger, { maxCommitRetries, delayBetwee
457
472
  // Process pre-keys and signed-pre-keys separately with specialized mutexes
458
473
  for (const key_ in data) {
459
474
  const keyType = key_
460
- if (keyType === 'pre-key') {
475
+ if (keyType === 'pre-key' || keyType === 'signed-pre-key') {
461
476
  await processPreKeyDeletions(data, keyType, state, logger)
462
477
  }
463
478
  }
@@ -222,7 +222,24 @@ const decryptMessageNode = (stanza, meId, meLid, repository, logger) => {
222
222
  async decrypt() {
223
223
  let decryptables = 0
224
224
  if (Array.isArray(stanza.content)) {
225
- for (const { tag, attrs, content } of stanza.content) {
225
+ // FIX: proses node non-skmsg (msg/pkmsg/plaintext) LEBIH DULU sebelum node
226
+ // skmsg. Root cause "bot diam/nunggu" pas ada member baru join grup lalu
227
+ // langsung kirim pesan: satu stanza berisi 2 node <enc> - satu tipe
228
+ // pkmsg/msg (isinya SenderKeyDistributionMessage buat kamu) dan satu lagi
229
+ // tipe skmsg (isi pesan asli, dienkripsi pakai sender-key grup). Urutan
230
+ // array stanza.content dari server TIDAK dijamin naruh pkmsg/msg duluan.
231
+ // Kalau skmsg kebetulan diproses duluan, sender-key belum ada di store ->
232
+ // decryptGroupMessage() gagal "No SenderKeyRecord found" -> pesan pertama
233
+ // dari member baru selalu gagal decrypt & jadi stub CIPHERTEXT, padahal
234
+ // SKDM-nya baru "sampai" sepersekian detik kemudian di iterasi berikutnya.
235
+ // Reorder di sini (stable, gak ubah urutan relatif node lain seperti
236
+ // verified_name/unavailable) supaya skmsg selalu diproses PALING TERAKHIR,
237
+ // setelah semua kemungkinan SKDM sudah didaftarkan ke sender-key store.
238
+ const orderedContent = [
239
+ ...stanza.content.filter(({ tag, attrs }) => !(tag === 'enc' && attrs.type === 'skmsg')),
240
+ ...stanza.content.filter(({ tag, attrs }) => tag === 'enc' && attrs.type === 'skmsg')
241
+ ]
242
+ for (const { tag, attrs, content } of orderedContent) {
226
243
  if (tag === 'verified_name' && content instanceof Uint8Array) {
227
244
  const cert = WAProto_1.proto.VerifiedNameCertificate.decode(content)
228
245
  const details = WAProto_1.proto.VerifiedNameCertificate.Details.decode(cert.details)
@@ -17,4 +17,5 @@ export * from './use-single-file-auth-state'
17
17
  export * from './use-multi-file-auth-state'
18
18
  export * from './link-preview'
19
19
  export * from './event-buffer'
20
- export * from './process-message'
20
+ export * from './process-message'
21
+ export * from './signal-housekeeping'
@@ -37,4 +37,5 @@ __exportStar(require("./use-single-file-auth-state"), exports)
37
37
  __exportStar(require("./use-multi-file-auth-state"), exports)
38
38
  __exportStar(require("./link-preview"), exports)
39
39
  __exportStar(require("./event-buffer"), exports)
40
- __exportStar(require("./process-message"), exports)
40
+ __exportStar(require("./process-message"), exports)
41
+ __exportStar(require("./signal-housekeeping"), exports)
@@ -0,0 +1,19 @@
1
+ import type { Logger } from 'pino'
2
+
3
+ export interface CleanupSignalAuthFolderOptions {
4
+ /** File yang mtime-nya lebih tua dari ini (dalam hari) akan dihapus. Default 30. */
5
+ maxAgeDays?: number
6
+ /** Prefix nama file yang boleh dibersihkan. Default ['session-', 'sender-key-']. */
7
+ prefixes?: string[]
8
+ logger?: Logger
9
+ }
10
+
11
+ export interface CleanupSignalAuthFolderResult {
12
+ removed: string[]
13
+ scanned: number
14
+ }
15
+
16
+ export function cleanupSignalAuthFolder(
17
+ folder: string,
18
+ options?: CleanupSignalAuthFolderOptions
19
+ ): Promise<CleanupSignalAuthFolderResult>
@@ -0,0 +1,73 @@
1
+ "use strict"
2
+
3
+ Object.defineProperty(exports, "__esModule", { value: true })
4
+ exports.cleanupSignalAuthFolder = cleanupSignalAuthFolder
5
+
6
+ const fs = require("fs/promises")
7
+ const path = require("path")
8
+
9
+ /**
10
+ * Bersihkan file session/sender-key yang sudah usang di folder auth yang dipakai
11
+ * bareng useMultiFileAuthState(). Tujuannya supaya store signal tidak numpuk terus
12
+ * selama bot jalan berbulan-bulan dengan ribuan kontak/grup yang sudah gak aktif.
13
+ *
14
+ * Aman dipakai karena: mtime sebuah file session-*.json / sender-key-*.json selalu
15
+ * ke-update setiap kali ada pertukaran pesan dengan kontak/grup itu (storeSession /
16
+ * storeSenderKey menulis ulang filenya). Jadi kalau sebuah file gak pernah disentuh
17
+ * lagi lebih dari `maxAgeDays`, itu tandanya kontak/grup itu memang sudah gak aktif -
18
+ * hapus filenya aman, nanti kalau ada pesan baru sesi/sender-key otomatis dibuat ulang
19
+ * dari prekey bundle / SenderKeyDistributionMessage yang baru.
20
+ *
21
+ * creds.json, app-state-sync-key-*.json, dan pre-key-*.json SENGAJA tidak disentuh
22
+ * di sini secara default - itu bukan sumber "numpuk", dan prekey yang sudah dipakai
23
+ * memang sudah dihapus otomatis lewat removePreKey() di libsignal.js.
24
+ *
25
+ * @param {string} folder - folder yang sama dipakai di useMultiFileAuthState()
26
+ * @param {object} [options]
27
+ * @param {number} [options.maxAgeDays=30] - file yang mtime-nya lebih tua dari ini akan dihapus
28
+ * @param {string[]} [options.prefixes] - prefix nama file yang boleh dibersihkan
29
+ * @param {import('pino').Logger} [options.logger]
30
+ * @returns {Promise<{ removed: string[], scanned: number }>}
31
+ */
32
+ async function cleanupSignalAuthFolder(folder, options = {}) {
33
+ const {
34
+ maxAgeDays = 30,
35
+ prefixes = ['session-', 'sender-key-'],
36
+ logger
37
+ } = options
38
+
39
+ const removed = []
40
+ let scanned = 0
41
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000
42
+ const now = Date.now()
43
+
44
+ let entries
45
+ try {
46
+ entries = await fs.readdir(folder)
47
+ } catch (err) {
48
+ logger?.warn?.({ err, folder }, '[cleanupSignalAuthFolder] gagal baca folder auth')
49
+ return { removed, scanned }
50
+ }
51
+
52
+ for (const name of entries) {
53
+ if (!prefixes.some((p) => name.startsWith(p))) {
54
+ continue
55
+ }
56
+ scanned++
57
+ const filePath = path.join(folder, name)
58
+ try {
59
+ const stat = await fs.stat(filePath)
60
+ if (now - stat.mtimeMs > maxAgeMs) {
61
+ await fs.unlink(filePath)
62
+ removed.push(name)
63
+ }
64
+ } catch {
65
+ // File mungkin sudah kehapus proses lain (race antar-worker), aman diabaikan.
66
+ }
67
+ }
68
+
69
+ logger?.info?.({ scanned, removedCount: removed.length, maxAgeDays }, '[cleanupSignalAuthFolder] selesai')
70
+ return { removed, scanned }
71
+ }
72
+
73
+ module.exports = { cleanupSignalAuthFolder }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pontalabs/baileys",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "PontaLabs Baileys is a lightweight, modern, and customizable WhatsApp Web API library built on Baileys.",
5
5
  "keywords": [
6
6
  "facebook",