@nexustechpro/baileys 2.1.2 → 2.1.6

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.
@@ -1,101 +1,119 @@
1
1
  import { getBinaryNodeChild, getBinaryNodeChildren, isHostedLidUser, isHostedPnUser, isJidMetaAI, isLidUser, isPnUser, jidNormalizedUser } from '../WABinary/index.js';
2
+ import { TC_TOKEN_INDEX_KEY, unixTimestampSeconds } from '../Utils/index.js';
3
+ import { randomBytes } from 'crypto'
2
4
  // Same phone-number pattern as WABinary's isJidBot, applied against the user
3
5
  // part so the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
4
6
  const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/;
7
+
8
+ const generateTcToken = () => {
9
+ const bucket = Math.floor(unixTimestampSeconds() / 604800)
10
+ const bucketByte = (bucket - 2900) & 0xff
11
+ return Buffer.concat([
12
+ Buffer.from([0x04, 0x01, bucketByte]),
13
+ randomBytes(8)
14
+ ])
15
+ }
16
+
17
+ export const preSeedTcToken = async ({ authState, jid, getLIDForPN, logger }) => {
18
+ const tcTokenJid = await resolveTcTokenJid(jid, getLIDForPN)
19
+ const existing = await authState.keys.get('tctoken', [tcTokenJid])
20
+ if (existing[tcTokenJid]?.token?.length) return { token: existing[tcTokenJid].token, storageJid: tcTokenJid }
21
+ const token = generateTcToken()
22
+ const timestamp = String(unixTimestampSeconds())
23
+ const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
24
+ const existingEntry = existing[tcTokenJid] || {}
25
+ await authState.keys.set({ tctoken: { [tcTokenJid]: { ...existingEntry, token, timestamp }, ...indexWrite } })
26
+ logger?.debug({ jid, tcTokenJid }, 'pre-seeded locally generated tctoken')
27
+ return { token, storageJid: tcTokenJid }
28
+ }
5
29
  /**
6
30
  * Mirrors WA Web's `Wid.isRegularUser()` (user ∧ ¬PSA ∧ ¬Bot). Used to gate tctoken
7
31
  * storage against malformed notifications — WA Web filters server-side but we
8
32
  * defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
9
33
  * Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
10
34
  */
11
- function isRegularUser(jid) {
12
- if (!jid)
13
- return false;
35
+ export function isRegularUser(jid) {
36
+ if (!jid) return false;
14
37
  const user = jid.split('@')[0] ?? '';
15
- if (user === '0')
16
- return false; // PSA
17
- if (BOT_PHONE_REGEX.test(user))
18
- return false; // Bot by phone pattern
19
- if (isJidMetaAI(jid))
20
- return false; // MetaAI (@bot server)
38
+ if (user === '0') return false; // PSA
39
+ if (BOT_PHONE_REGEX.test(user)) return false; // Bot by phone pattern
40
+ if (isJidMetaAI(jid)) return false; // MetaAI (@bot server)
21
41
  return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'));
22
42
  }
43
+
23
44
  const TC_TOKEN_BUCKET_DURATION = 604800; // 7 days
24
45
  const TC_TOKEN_NUM_BUCKETS = 4; // ~28-day rolling window
25
- /** Sentinel key under `tctoken` store holding a JSON array of tracked storage JIDs for cross-session pruning. */
26
- export const TC_TOKEN_INDEX_KEY = '__index';
27
- /** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
46
+
28
47
  export async function readTcTokenIndex(keys) {
29
- const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY]);
30
- const entry = data[TC_TOKEN_INDEX_KEY];
31
- if (!entry?.token?.length)
32
- return [];
33
- try {
34
- const parsed = JSON.parse(Buffer.from(entry.token).toString());
35
- if (!Array.isArray(parsed))
36
- return [];
37
- return parsed.filter((j) => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY);
38
- }
39
- catch {
40
- return [];
48
+ // Try new sentinel key first, fall back to old 'index' key
49
+ for (const key of [TC_TOKEN_INDEX_KEY, 'index']) {
50
+ const data = await keys.get('tctoken', [key])
51
+ const entry = data[key]
52
+ if (!entry?.token?.length) continue
53
+ try {
54
+ const parsed = JSON.parse(Buffer.from(entry.token).toString())
55
+ if (!Array.isArray(parsed)) continue
56
+ return parsed.filter(j => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY && j !== 'index')
57
+ } catch {
58
+ continue
59
+ }
41
60
  }
61
+ return []
42
62
  }
43
- /** Build a SignalDataSet fragment that writes the merged index (persisted ∪ added) under the sentinel key. */
63
+
44
64
  export async function buildMergedTcTokenIndexWrite(keys, addedJids) {
45
- const persisted = await readTcTokenIndex(keys);
46
- const merged = new Set(persisted);
65
+ const persisted = await readTcTokenIndex(keys)
66
+ const merged = new Set(persisted)
47
67
  for (const jid of addedJids) {
48
- if (jid && jid !== TC_TOKEN_INDEX_KEY)
49
- merged.add(jid);
68
+ if (jid && jid !== TC_TOKEN_INDEX_KEY && jid !== 'index') merged.add(jid)
50
69
  }
51
70
  return {
52
71
  [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
53
- };
72
+ }
54
73
  }
55
74
  // WA Web has separate sender/receiver AB props for these but they're identical today
56
75
  export function isTcTokenExpired(timestamp) {
57
- if (timestamp === null || timestamp === undefined)
58
- return true;
76
+ if (timestamp === null || timestamp === undefined) return true;
59
77
  const ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp;
60
- if (isNaN(ts))
61
- return true;
78
+ if (isNaN(ts)) return true;
62
79
  const now = Math.floor(Date.now() / 1000);
63
80
  const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION);
64
81
  const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1);
65
82
  const cutoffTimestamp = cutoffBucket * TC_TOKEN_BUCKET_DURATION;
66
83
  return ts < cutoffTimestamp;
67
84
  }
85
+
68
86
  export function shouldSendNewTcToken(senderTimestamp) {
69
- if (senderTimestamp === undefined)
70
- return true;
87
+ if (senderTimestamp === undefined) return true;
71
88
  const now = Math.floor(Date.now() / 1000);
72
89
  const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION);
73
90
  const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION);
74
91
  return currentBucket > senderBucket;
75
92
  }
93
+
76
94
  /** Resolve JID to LID for tctoken storage (WA Web stores under LID) */
77
95
  export async function resolveTcTokenJid(jid, getLIDForPN) {
78
- if (isLidUser(jid))
79
- return jid;
80
- const lid = await getLIDForPN(jid);
81
- return lid ?? jid;
96
+ if (isLidUser(jid)) return jid
97
+ const lid = await getLIDForPN(jid)
98
+ if (!lid) return null
99
+ return lid
82
100
  }
101
+
83
102
  /** Resolve target JID for issuing privacy token based on AB prop 14303 */
84
103
  export async function resolveIssuanceJid(jid, issueToLid, getLIDForPN, getPNForLID) {
85
104
  if (issueToLid) {
86
- if (isLidUser(jid))
87
- return jid;
105
+ if (isLidUser(jid)) return jid;
88
106
  const lid = await getLIDForPN(jid);
89
107
  return lid ?? jid;
90
108
  }
91
- if (!isLidUser(jid))
92
- return jid;
109
+ if (!isLidUser(jid)) return jid;
93
110
  if (getPNForLID) {
94
111
  const pn = await getPNForLID(jid);
95
112
  return pn ?? jid;
96
113
  }
97
114
  return jid;
98
115
  }
116
+
99
117
  export async function buildTcTokenFromJid({ authState, jid, baseContent = [], getLIDForPN }) {
100
118
  try {
101
119
  const storageJid = await resolveTcTokenJid(jid, getLIDForPN);
@@ -114,49 +132,31 @@ export async function buildTcTokenFromJid({ authState, jid, baseContent = [], ge
114
132
  }
115
133
  return baseContent.length > 0 ? baseContent : undefined;
116
134
  }
117
- baseContent.push({
118
- tag: 'tctoken',
119
- attrs: {},
120
- content: tcTokenBuffer
121
- });
135
+ baseContent.push({ tag: 'tctoken', attrs: {}, content: tcTokenBuffer });
122
136
  return baseContent;
123
- }
124
- catch (error) {
137
+ } catch (error) {
125
138
  return baseContent.length > 0 ? baseContent : undefined;
126
139
  }
127
140
  }
141
+
128
142
  export async function storeTcTokensFromIqResult({ result, fallbackJid, keys, getLIDForPN, onNewJidStored }) {
129
143
  const tokensNode = getBinaryNodeChild(result, 'tokens');
130
- if (!tokensNode)
131
- return;
144
+ if (!tokensNode) return;
132
145
  const tokenNodes = getBinaryNodeChildren(tokensNode, 'token');
133
146
  for (const tokenNode of tokenNodes) {
134
- if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
135
- continue;
136
- }
147
+ if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) continue;
137
148
  // In notifications tokenNode.attrs.jid is your own device JID, not the sender's
138
149
  const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid);
139
- if (!isRegularUser(rawJid))
140
- continue;
150
+ if (!isRegularUser(rawJid)) continue;
141
151
  const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN);
142
152
  const existingTcData = await keys.get('tctoken', [storageJid]);
143
153
  const existingEntry = existingTcData[storageJid];
144
154
  const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0;
145
155
  const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0;
146
156
  // timestamp-less tokens would be immediately expired
147
- if (!incomingTs)
148
- continue;
149
- if (existingTs > 0 && existingTs > incomingTs)
150
- continue;
151
- await keys.set({
152
- tctoken: {
153
- [storageJid]: {
154
- ...existingEntry,
155
- token: Buffer.from(tokenNode.content),
156
- timestamp: tokenNode.attrs.t
157
- }
158
- }
159
- });
157
+ if (!incomingTs) continue;
158
+ if (existingTs > 0 && existingTs > incomingTs) continue;
159
+ await keys.set({ tctoken: { [storageJid]: { ...existingEntry, token: Buffer.from(tokenNode.content), timestamp: tokenNode.attrs.t } } });
160
160
  onNewJidStored?.(storageJid);
161
161
  }
162
162
  }
@@ -2,17 +2,30 @@ import { Mutex } from 'async-mutex'
2
2
  import { mkdir, readFile, rename, stat, unlink, writeFile, readdir } from 'fs/promises'
3
3
  import { join } from 'path'
4
4
  import { createHash } from 'crypto'
5
+ import { Keyv } from 'keyv'
5
6
  import { proto } from '../../WAProto/index.js'
6
7
  import { initAuthCreds } from './auth-utils.js'
7
8
  import { BufferJSON } from './generics.js'
9
+ import { PROTOCOL_ADAPTERS } from '../WABinary/index.js'
8
10
 
9
- // ─── Constants ────────────────────────────────────────────────────────────────
10
11
  const CURRENT_VERSION = 1
11
12
  const DEFAULT_PREKEY_RETENTION = 150
12
13
  const DEFAULT_CLEANUP_THRESHOLD = 50
13
14
  const CLEANUP_INTERVAL_MS = 10 * 60 * 1000
14
15
 
15
- // ─── File Lock Registry ───────────────────────────────────────────────────────
16
+ const bootstrapCreds = (raw) => {
17
+ const c = raw ?? initAuthCreds()
18
+ if (!c.__version || c.__version < CURRENT_VERSION) c.__version = CURRENT_VERSION
19
+ return c
20
+ }
21
+
22
+ const patchAppStateKey = (type, v) =>
23
+ type === 'app-state-sync-key' && v ? proto.Message.AppStateSyncKeyData.fromObject(v) : v
24
+
25
+ const computeChecksum = (data) => createHash('sha256').update(data).digest('hex')
26
+
27
+ // ─── File-based auth state ────────────────────────────────────────────────────
28
+
16
29
  const fileLocks = new Map()
17
30
  const getFileLock = (path) => {
18
31
  if (!fileLocks.has(path)) fileLocks.set(path, new Mutex())
@@ -22,31 +35,13 @@ const releaseFileLock = (path) => {
22
35
  if (fileLocks.has(path) && !fileLocks.get(path).isLocked()) fileLocks.delete(path)
23
36
  }
24
37
 
25
- // ─── Checksum ─────────────────────────────────────────────────────────────────
26
- const computeChecksum = (data) => createHash('sha256').update(data).digest('hex')
27
-
28
- /**
29
- * Production-grade multi-file auth state for Baileys.
30
- * Atomic writes, checksum integrity, and smart prekey cleanup.
31
- * Compatible with standard Baileys auth folder format.
32
- *
33
- * @param {string} folder - Directory to store auth files
34
- * @param {object} [options] - Configuration options
35
- * @param {number} [options.preKeyRetention=150] - How many recent prekeys to keep
36
- * @param {number} [options.cleanupThreshold=50] - How many new prekeys trigger a cleanup
37
- * @param {object} [options.logger] - Optional logger with .info/.warn methods
38
- */
39
38
  export const useMultiFileAuthState = async (folder, options = {}) => {
40
- const {
41
- preKeyRetention = DEFAULT_PREKEY_RETENTION,
42
- cleanupThreshold = DEFAULT_CLEANUP_THRESHOLD,
43
- logger,
44
- } = options
39
+ const { preKeyRetention = DEFAULT_PREKEY_RETENTION, cleanupThreshold = DEFAULT_CLEANUP_THRESHOLD, logger } = options
40
+
45
41
  const fixFileName = (file) => file?.replace(/\//g, '__')?.replace(/:/g, '-')
46
42
  const filePath = (file) => join(folder, fixFileName(file))
47
43
  const tmpPath = (file) => filePath(file) + '.tmp'
48
44
 
49
- // ─── Folder Bootstrap ───────────────────────────────────────────────────────
50
45
  const folderInfo = await stat(folder).catch(() => null)
51
46
  if (folderInfo) {
52
47
  if (!folderInfo.isDirectory()) throw new Error(`Path exists but is not a directory: ${folder}`)
@@ -54,16 +49,13 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
54
49
  await mkdir(folder, { recursive: true })
55
50
  }
56
51
 
57
- // ─── Atomic Write ────────────────────────────────────────────────────────────
58
52
  const writeData = async (data, file) => {
59
53
  const fp = filePath(file)
60
54
  const tp = tmpPath(file)
61
- const mutex = getFileLock(fp)
62
- const release = await mutex.acquire()
55
+ const release = await getFileLock(fp).acquire()
63
56
  try {
64
57
  const serialized = JSON.stringify(data, BufferJSON.replacer)
65
- const checksum = computeChecksum(serialized)
66
- const payload = JSON.stringify({ data: JSON.parse(serialized), __checksum: checksum })
58
+ const payload = JSON.stringify({ data: JSON.parse(serialized), __checksum: computeChecksum(serialized) })
67
59
  await writeFile(tp, payload)
68
60
  try {
69
61
  await rename(tp, fp)
@@ -77,11 +69,9 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
77
69
  }
78
70
  }
79
71
 
80
- // ─── Read with Checksum Verification ─────────────────────────────────────────
81
72
  const readData = async (file) => {
82
73
  const fp = filePath(file)
83
- const mutex = getFileLock(fp)
84
- const release = await mutex.acquire()
74
+ const release = await getFileLock(fp).acquire()
85
75
  try {
86
76
  const raw = await readFile(fp, { encoding: 'utf-8' }).catch(() => null)
87
77
  if (!raw) return null
@@ -92,28 +82,26 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
92
82
  if (expected !== parsed.__checksum) {
93
83
  logger?.warn({ file }, 'checksum mismatch — reinitializing file')
94
84
  await writeFile(fp, JSON.stringify({ data: parsed.data, __checksum: computeChecksum(JSON.stringify(parsed.data)) })).catch(() => { })
95
- return JSON.parse(JSON.stringify(parsed.data), BufferJSON.reviver)
96
85
  }
97
86
  return JSON.parse(JSON.stringify(parsed.data), BufferJSON.reviver)
98
87
  }
99
- // legacy file — rewrite with checksum
100
88
  const reserialized = JSON.stringify(JSON.parse(raw, BufferJSON.reviver), BufferJSON.replacer)
101
- const checksum = computeChecksum(reserialized)
102
- await writeFile(fp, JSON.stringify({ data: JSON.parse(reserialized), __checksum: checksum })).catch(() => { })
89
+ await writeFile(fp, JSON.stringify({ data: JSON.parse(reserialized), __checksum: computeChecksum(reserialized) })).catch(() => { })
103
90
  return JSON.parse(raw, BufferJSON.reviver)
104
91
  } catch (err) {
105
92
  logger?.warn({ file, err: err.message }, 'failed to read auth file — reinitializing')
106
93
  await unlink(fp).catch(() => { })
107
94
  return null
108
95
  }
109
- } finally { release(); releaseFileLock(fp) }
96
+ } finally {
97
+ release()
98
+ releaseFileLock(fp)
99
+ }
110
100
  }
111
101
 
112
- // ─── Remove File ────────────────────────────────────────────────────────────
113
102
  const removeData = async (file) => {
114
103
  const fp = filePath(file)
115
- const mutex = getFileLock(fp)
116
- const release = await mutex.acquire()
104
+ const release = await getFileLock(fp).acquire()
117
105
  try {
118
106
  await unlink(fp).catch(() => { })
119
107
  } finally {
@@ -122,34 +110,23 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
122
110
  }
123
111
  }
124
112
 
125
- // ─── Credentials Bootstrap ──────────────────────────────────────────────────
126
- let creds = (await readData('creds.json')) || initAuthCreds()
127
- if (!creds.__version) {
128
- creds.__version = CURRENT_VERSION
129
- } else if (creds.__version < CURRENT_VERSION) {
130
- creds.__version = CURRENT_VERSION
131
- }
132
-
133
- // ─── Prekey Cleanup ─────────────────────────────────────────────────────────
113
+ let creds = bootstrapCreds(await readData('creds.json'))
134
114
  let cleanupRunning = false
135
115
  let lastCleanupAt = 0
136
116
  let lastCleanedPreKeyId = creds.nextPreKeyId
137
117
 
138
118
  const cleanOldPreKeys = async () => {
139
119
  const now = Date.now()
140
- if (cleanupRunning) return
141
- if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) return
120
+ if (cleanupRunning || now - lastCleanupAt < CLEANUP_INTERVAL_MS) return
142
121
  cleanupRunning = true
143
122
  try {
144
123
  const minId = creds.nextPreKeyId - preKeyRetention
145
124
  if (minId <= 0) return
146
125
  const files = await readdir(folder)
147
- const targets = []
148
- for (const file of files) {
149
- const match = file.match(/^pre-key-(\d+)\.json(\.tmp)?$/)
150
- if (!match) continue
151
- if (parseInt(match[1], 10) < minId) targets.push(join(folder, file))
152
- }
126
+ const targets = files
127
+ .map(f => f.match(/^pre-key-(\d+)\.json(\.tmp)?$/))
128
+ .filter(m => m && parseInt(m[1], 10) < minId)
129
+ .map(m => join(folder, m[0]))
153
130
  if (!targets.length) return
154
131
  await Promise.all(targets.map(f => unlink(f).catch(() => { })))
155
132
  lastCleanupAt = Date.now()
@@ -164,7 +141,6 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
164
141
 
165
142
  cleanOldPreKeys().catch(() => { })
166
143
 
167
- // ─── Stats ──────────────────────────────────────────────────────────────────
168
144
  const getStats = async () => {
169
145
  const files = await readdir(folder).catch(() => [])
170
146
  const preKeyFiles = files.filter(f => /^pre-key-\d+\.json$/.test(f))
@@ -184,9 +160,7 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
184
160
  const data = {}
185
161
  await Promise.all(ids.map(async (id) => {
186
162
  let value = await readData(`${type}-${id}.json`)
187
- if (type === 'app-state-sync-key' && value) {
188
- value = proto.Message.AppStateSyncKeyData.fromObject(value)
189
- }
163
+ if (type === 'app-state-sync-key' && value) value = proto.Message.AppStateSyncKeyData.fromObject(value)
190
164
  data[id] = value
191
165
  }))
192
166
  return data
@@ -204,11 +178,185 @@ export const useMultiFileAuthState = async (folder, options = {}) => {
204
178
  },
205
179
  },
206
180
  saveCreds: async () => {
207
- if (creds.nextPreKeyId - lastCleanedPreKeyId >= cleanupThreshold) {
208
- cleanOldPreKeys().catch(() => { })
209
- }
181
+ if (creds.nextPreKeyId - lastCleanedPreKeyId >= cleanupThreshold) cleanOldPreKeys().catch(() => { })
210
182
  return writeData(creds, 'creds.json')
211
183
  },
212
184
  getStats,
213
185
  }
214
- }
186
+ }
187
+
188
+ // ─── Keyv-backed auth state ───────────────────────────────────────────────────
189
+
190
+ const ser = (v) => {
191
+ if (v == null) return null
192
+ const serialized = JSON.stringify(v, BufferJSON.replacer)
193
+ return { data: JSON.parse(serialized), __checksum: computeChecksum(serialized) }
194
+ }
195
+
196
+ const unwrapNestedValue = (obj) => {
197
+ let cur = obj
198
+ while (cur && typeof cur === 'object' && !Array.isArray(cur) && Object.keys(cur).length === 1 && 'value' in cur) cur = cur.value
199
+ return cur
200
+ }
201
+
202
+ const deser = (raw, logger, onRewrite) => {
203
+ if (raw == null) return null
204
+ let parsed
205
+ if (typeof raw === 'string') {
206
+ try { parsed = JSON.parse(raw) } catch (err) {
207
+ logger?.warn?.({ err: err.message }, '[Auth] failed to parse stored value — treating as missing')
208
+ return null
209
+ }
210
+ } else {
211
+ parsed = raw
212
+ }
213
+ parsed = unwrapNestedValue(parsed)
214
+ if (parsed == null) return null
215
+ if (typeof parsed === 'object' && '__checksum' in parsed) {
216
+ const innerData = unwrapNestedValue(parsed.data)
217
+ const serializedInner = JSON.stringify(innerData)
218
+ const expected = computeChecksum(serializedInner)
219
+ if (expected !== parsed.__checksum) {
220
+ logger?.warn?.('[Auth] checksum mismatch or legacy-wrapped value — reinitializing')
221
+ onRewrite?.(JSON.stringify({ data: innerData, __checksum: expected }))
222
+ }
223
+ return JSON.parse(serializedInner, BufferJSON.reviver)
224
+ }
225
+ return JSON.parse(JSON.stringify(parsed), BufferJSON.reviver)
226
+ }
227
+
228
+ // Maps our unified collectionName option to each adapter's actual constructor key
229
+ const COLLECTION_OPTION_KEY = {
230
+ '@keyv/mongo': 'collection',
231
+ '@keyv/postgres': 'table',
232
+ '@keyv/mysql': 'table',
233
+ '@keyv/sqlite': 'table',
234
+ }
235
+
236
+ const _adapterCache = new Map()
237
+ export const clearAdapterCache = () => _adapterCache.clear()
238
+
239
+ const resolveAdapter = async (connectionString, collectionName) => {
240
+ const cacheKey = collectionName ? `${connectionString}::${collectionName}` : connectionString
241
+ if (_adapterCache.has(cacheKey)) return _adapterCache.get(cacheKey)
242
+ let protocol
243
+ try { protocol = new URL(connectionString).protocol } catch { throw new Error(`[Auth] Invalid connection string: "${connectionString}"`) }
244
+ const pkg = PROTOCOL_ADAPTERS[protocol]
245
+ if (!pkg) throw new Error(`[Auth] Unsupported protocol "${protocol}" — supported: redis, mongodb, postgresql, mysql, sqlite, etcd, memcache. Pass a pre-built KeyvStoreAdapter instance for anything else.`)
246
+ let mod
247
+ try { mod = await import(pkg) } catch (e) {
248
+ if (e.code === 'ERR_MODULE_NOT_FOUND') throw new Error(`[Auth] "${pkg}" is required for this backend — install it: npm i ${pkg}`)
249
+ throw e
250
+ }
251
+ const Adapter = mod.default ?? mod[Object.keys(mod)[0]]
252
+ const optionKey = COLLECTION_OPTION_KEY[pkg]
253
+ // Pass one merged options object — Postgres/MySQL silently ignore a second positional argument
254
+ const adapter = (collectionName && optionKey)
255
+ ? new Adapter({ url: connectionString, uri: connectionString, [optionKey]: collectionName })
256
+ : new Adapter(connectionString)
257
+ adapter.setMaxListeners?.(0)
258
+ _adapterCache.set(cacheKey, adapter)
259
+ return adapter
260
+ }
261
+
262
+ const isAdapterLike = (v) => v && typeof v === 'object' && typeof v.get === 'function' && typeof v.set === 'function' && typeof v.delete === 'function' && typeof v.clear === 'function'
263
+
264
+ const resolveStore = async (backend, collectionName) => {
265
+ if (backend == null) return null
266
+ if (typeof backend === 'string') return resolveAdapter(backend, collectionName)
267
+ if (backend instanceof Keyv) return backend.opts?.store ?? backend.store ?? null
268
+ if (isAdapterLike(backend)) return backend
269
+ throw new Error('[Auth] backend must be a connection string, Keyv instance, or KeyvStoreAdapter')
270
+ }
271
+
272
+ export const useKeyvAuthState = async (sessionId, backend, opts = {}) => {
273
+ const { preKeyRetention = DEFAULT_PREKEY_RETENTION, cleanupThreshold = DEFAULT_CLEANUP_THRESHOLD, logger, collectionName } = opts
274
+
275
+ const store = await resolveStore(backend, collectionName)
276
+ const keyv = store ? new Keyv({ store, namespace: sessionId }) : new Keyv({ namespace: sessionId })
277
+ keyv.on('error', (err) => logger?.warn?.({ err: err?.message }, '[Auth] store error'))
278
+
279
+ const keyName = (type, id) => `${type}-${id}`
280
+
281
+ let creds = bootstrapCreds(deser(await keyv.get('creds'), logger, (fixed) => keyv.set('creds', fixed)))
282
+ let cleanupRunning = false
283
+ let lastCleanupAt = 0
284
+ let lastCleanedPreKeyId = creds.nextPreKeyId
285
+
286
+ const cleanOldPreKeys = async () => {
287
+ const now = Date.now()
288
+ if (cleanupRunning || now - lastCleanupAt < CLEANUP_INTERVAL_MS) return
289
+ cleanupRunning = true
290
+ try {
291
+ const minId = creds.nextPreKeyId - preKeyRetention
292
+ if (minId <= 0) return
293
+ const ids = []
294
+ for (let id = Math.max(0, lastCleanedPreKeyId - preKeyRetention); id < minId; id++) ids.push(keyName('pre-key', id))
295
+ if (!ids.length) return
296
+ await Promise.all(ids.map((k) => keyv.delete(k).catch(() => { })))
297
+ lastCleanupAt = Date.now()
298
+ lastCleanedPreKeyId = creds.nextPreKeyId
299
+ logger?.info?.({ deleted: ids.length, minId }, '[Auth] prekey cleanup done')
300
+ } catch (e) {
301
+ logger?.warn?.({ err: e.message }, '[Auth] prekey cleanup failed')
302
+ } finally {
303
+ cleanupRunning = false
304
+ }
305
+ }
306
+
307
+ cleanOldPreKeys().catch(() => { })
308
+
309
+ const keys = {
310
+ get: async (type, ids) => {
311
+ const data = {}
312
+ if (!ids.length) return data
313
+ const rawKeys = ids.map((id) => keyName(type, id))
314
+ const rawValues = await keyv.getMany(rawKeys)
315
+ const rewrites = []
316
+ ids.forEach((id, i) => {
317
+ data[id] = patchAppStateKey(type, deser(rawValues[i], logger, (fixed) => rewrites.push(keyv.set(rawKeys[i], fixed))))
318
+ })
319
+ if (rewrites.length) await Promise.all(rewrites).catch(() => { })
320
+ return data
321
+ },
322
+ set: async (data) => {
323
+ const setEntries = []
324
+ const delKeys = []
325
+ for (const type in data) {
326
+ for (const id in data[type]) {
327
+ const value = data[type][id]
328
+ const key = keyName(type, id)
329
+ if (value != null) setEntries.push({ key, value: ser(value) })
330
+ else delKeys.push(key)
331
+ }
332
+ }
333
+ await Promise.all([
334
+ setEntries.length ? keyv.setMany(setEntries) : null,
335
+ delKeys.length ? keyv.deleteMany(delKeys) : null,
336
+ ])
337
+ },
338
+ }
339
+
340
+ const saveCreds = async () => {
341
+ if (creds.nextPreKeyId - lastCleanedPreKeyId >= cleanupThreshold) cleanOldPreKeys().catch(() => { })
342
+ await keyv.set('creds', ser(creds))
343
+ }
344
+
345
+ const clearSession = async () => {
346
+ const store = keyv.opts?.store ?? keyv.store
347
+ if (store && typeof store.deleteMany === 'function' && typeof store.iterator === 'function') {
348
+ const toDelete = []
349
+ for await (const [key] of store.iterator(sessionId)) toDelete.push(key)
350
+ if (toDelete.length) await store.deleteMany(toDelete)
351
+ } else {
352
+ await keyv.clear()
353
+ }
354
+ await close()
355
+ }
356
+
357
+ const close = async () => { keyv.removeAllListeners?.() }
358
+
359
+ return { state: { creds, keys }, saveCreds, clearSession }
360
+ }
361
+
362
+ export { Keyv }
@@ -13,7 +13,9 @@ const getUserAgent = (config) => {
13
13
  secondary: config.version[1],
14
14
  tertiary: config.version[2]
15
15
  },
16
- platform: proto.ClientPayload.UserAgent.Platform.WEB,
16
+ platform: config.browser[1].toLocaleLowerCase().includes('android')
17
+ ? proto.ClientPayload.UserAgent.Platform.MACOS
18
+ : proto.ClientPayload.UserAgent.Platform.WEB,
17
19
  releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
18
20
  osVersion: '0.1',
19
21
  device: 'Desktop',
@@ -43,7 +45,9 @@ const getClientPayload = (config) => {
43
45
  connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED,
44
46
  userAgent: getUserAgent(config)
45
47
  };
46
- payload.webInfo = getWebInfo(config);
48
+ if (!config.browser[1].toLowerCase().includes('android')) {
49
+ payload.webInfo = getWebInfo(config)
50
+ }
47
51
  return payload;
48
52
  };
49
53
  export const generateLoginNode = (userJid, config) => {
@@ -61,6 +65,9 @@ export const generateLoginNode = (userJid, config) => {
61
65
  };
62
66
  const getPlatformType = (platform) => {
63
67
  const platformType = platform.toUpperCase();
68
+ if (platformType === 'ANDROID') {
69
+ return proto.DeviceProps.PlatformType.ANDROID_PHONE;
70
+ }
64
71
  return (proto.DeviceProps.PlatformType[platformType] ||
65
72
  proto.DeviceProps.PlatformType.CHROME);
66
73
  };
@@ -77,6 +84,7 @@ export const generateRegistrationNode = ({ registrationId, signedPreKey, signedI
77
84
  historySyncConfig: {
78
85
  storageQuotaMb: 569150,
79
86
  inlineInitialPayloadInE2EeMsg: true,
87
+ recentSyncDaysLimit: undefined,
80
88
  supportCallLogHistory: false,
81
89
  supportBotUserAgentChatHistory: true,
82
90
  supportCagReactionsAndPolls: true,
@@ -84,7 +92,11 @@ export const generateRegistrationNode = ({ registrationId, signedPreKey, signedI
84
92
  supportRecentSyncChunkMessageCountTuning: true,
85
93
  supportHostedGroupMsg: true,
86
94
  supportFbidBotChatHistory: true,
87
- supportMessageAssociation: true
95
+ supportAddOnHistorySyncMigration: undefined,
96
+ supportMessageAssociation: true,
97
+ supportGroupHistory: false,
98
+ onDemandReady: undefined,
99
+ supportGuestChat: undefined
88
100
  },
89
101
  version: {
90
102
  primary: 10,