@nexustechpro/baileys 2.0.1 → 2.0.5

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.
Files changed (102) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +924 -1299
  3. package/lib/Defaults/baileys-version.json +6 -2
  4. package/lib/Defaults/index.js +172 -172
  5. package/lib/Signal/libsignal.js +380 -292
  6. package/lib/Signal/lid-mapping.js +264 -171
  7. package/lib/Socket/Client/index.js +2 -2
  8. package/lib/Socket/Client/types.js +10 -10
  9. package/lib/Socket/Client/websocket.js +45 -310
  10. package/lib/Socket/business.js +375 -375
  11. package/lib/Socket/chats.js +909 -963
  12. package/lib/Socket/communities.js +430 -430
  13. package/lib/Socket/groups.js +342 -342
  14. package/lib/Socket/index.js +22 -22
  15. package/lib/Socket/messages-recv.js +777 -743
  16. package/lib/Socket/messages-send.js +667 -393
  17. package/lib/Socket/mex.js +50 -50
  18. package/lib/Socket/newsletter.js +148 -148
  19. package/lib/Socket/nexus-handler.js +75 -261
  20. package/lib/Socket/socket.js +709 -1201
  21. package/lib/Store/index.js +5 -5
  22. package/lib/Store/make-cache-manager-store.js +81 -81
  23. package/lib/Store/make-in-memory-store.js +416 -416
  24. package/lib/Store/make-ordered-dictionary.js +81 -81
  25. package/lib/Store/object-repository.js +30 -30
  26. package/lib/Types/Auth.js +1 -1
  27. package/lib/Types/Bussines.js +1 -1
  28. package/lib/Types/Call.js +1 -1
  29. package/lib/Types/Chat.js +7 -7
  30. package/lib/Types/Contact.js +1 -1
  31. package/lib/Types/Events.js +1 -1
  32. package/lib/Types/GroupMetadata.js +1 -1
  33. package/lib/Types/Label.js +24 -24
  34. package/lib/Types/LabelAssociation.js +6 -6
  35. package/lib/Types/Message.js +10 -10
  36. package/lib/Types/Newsletter.js +28 -28
  37. package/lib/Types/Product.js +1 -1
  38. package/lib/Types/Signal.js +1 -1
  39. package/lib/Types/Socket.js +2 -2
  40. package/lib/Types/State.js +12 -12
  41. package/lib/Types/USync.js +1 -1
  42. package/lib/Types/index.js +25 -25
  43. package/lib/Utils/auth-utils.js +264 -256
  44. package/lib/Utils/baileys-event-stream.js +55 -55
  45. package/lib/Utils/browser-utils.js +27 -27
  46. package/lib/Utils/business.js +228 -230
  47. package/lib/Utils/chat-utils.js +694 -764
  48. package/lib/Utils/crypto.js +109 -135
  49. package/lib/Utils/decode-wa-message.js +310 -314
  50. package/lib/Utils/event-buffer.js +547 -547
  51. package/lib/Utils/generics.js +297 -297
  52. package/lib/Utils/history.js +91 -83
  53. package/lib/Utils/index.js +21 -20
  54. package/lib/Utils/key-store.js +17 -0
  55. package/lib/Utils/link-preview.js +97 -88
  56. package/lib/Utils/logger.js +2 -2
  57. package/lib/Utils/lt-hash.js +47 -47
  58. package/lib/Utils/make-mutex.js +39 -39
  59. package/lib/Utils/message-retry-manager.js +148 -148
  60. package/lib/Utils/messages-media.js +534 -532
  61. package/lib/Utils/messages.js +705 -705
  62. package/lib/Utils/noise-handler.js +255 -255
  63. package/lib/Utils/pre-key-manager.js +105 -105
  64. package/lib/Utils/process-message.js +412 -412
  65. package/lib/Utils/signal.js +160 -158
  66. package/lib/Utils/use-multi-file-auth-state.js +120 -120
  67. package/lib/Utils/validate-connection.js +194 -194
  68. package/lib/WABinary/constants.js +1300 -1300
  69. package/lib/WABinary/decode.js +237 -237
  70. package/lib/WABinary/encode.js +232 -232
  71. package/lib/WABinary/generic-utils.js +252 -211
  72. package/lib/WABinary/index.js +5 -5
  73. package/lib/WABinary/jid-utils.js +279 -95
  74. package/lib/WABinary/types.js +1 -1
  75. package/lib/WAM/BinaryInfo.js +9 -9
  76. package/lib/WAM/constants.js +22852 -22852
  77. package/lib/WAM/encode.js +149 -149
  78. package/lib/WAM/index.js +3 -3
  79. package/lib/WAUSync/Protocols/USyncContactProtocol.js +28 -28
  80. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +53 -53
  81. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +26 -26
  82. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +37 -37
  83. package/lib/WAUSync/Protocols/UsyncBotProfileProtocol.js +50 -50
  84. package/lib/WAUSync/Protocols/UsyncLIDProtocol.js +28 -28
  85. package/lib/WAUSync/Protocols/index.js +4 -4
  86. package/lib/WAUSync/USyncQuery.js +93 -93
  87. package/lib/WAUSync/USyncUser.js +22 -22
  88. package/lib/WAUSync/index.js +3 -3
  89. package/lib/index.js +66 -66
  90. package/package.json +171 -144
  91. package/lib/Signal/Group/ciphertext-message.js +0 -12
  92. package/lib/Signal/Group/group-session-builder.js +0 -30
  93. package/lib/Signal/Group/group_cipher.js +0 -100
  94. package/lib/Signal/Group/index.js +0 -12
  95. package/lib/Signal/Group/keyhelper.js +0 -18
  96. package/lib/Signal/Group/sender-chain-key.js +0 -26
  97. package/lib/Signal/Group/sender-key-distribution-message.js +0 -63
  98. package/lib/Signal/Group/sender-key-message.js +0 -66
  99. package/lib/Signal/Group/sender-key-name.js +0 -48
  100. package/lib/Signal/Group/sender-key-record.js +0 -41
  101. package/lib/Signal/Group/sender-key-state.js +0 -84
  102. package/lib/Signal/Group/sender-message-key.js +0 -26
@@ -1,1202 +1,710 @@
1
- import { Boom } from "@hapi/boom"
2
- import { randomBytes } from "crypto"
3
- import { URL } from "url"
4
- import { promisify } from "util"
5
- import { proto } from "../../WAProto/index.js"
6
- import {
7
- DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT,
8
- MIN_UPLOAD_INTERVAL, NOISE_WA_HEADER, UPLOAD_TIMEOUT, BATCH_SIZE
9
- } from "../Defaults/index.js"
10
- import { DisconnectReason } from "../Types/index.js"
11
- import {
12
- addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, bytesToCrockford,
13
- configureSuccessfulPairing, Curve, derivePairingCodeKey, generateLoginNode, generateMdTagPrefix,
14
- generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode,
15
- makeEventBuffer, makeNoiseHandler, promiseTimeout
16
- } from "../Utils/index.js"
17
- import { getPlatformId } from "../Utils/browser-utils.js"
18
- import {
19
- assertNodeErrorFree, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild,
20
- getBinaryNodeChildren, isLidUser, jidDecode, jidEncode, S_WHATSAPP_NET
21
- } from "../WABinary/index.js"
22
- import { BinaryInfo } from "../WAM/BinaryInfo.js"
23
- import { USyncQuery, USyncUser } from "../WAUSync/index.js"
24
- import { WebSocketClient } from "./Client/index.js"
25
-
26
- // ==================== CONSTANTS ====================
27
- const CONSTANTS = {
28
- MAX_RECONNECT: 5,
29
- MAX_FAILED_PINGS: 6,
30
- PREKEY_CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
31
- PREKEY_MIN_INTERVAL: 5 * 60 * 1000, // 5 minutes
32
- PREKEY_CRITICAL: 3,
33
- SESSION_CLEANUP_INTERVAL: 10 * 60 * 1000, // 10 minutes
34
- SESSION_MAX_SIZE: 1000,
35
- HEALTH_CHECK_MULTIPLIER: 4
36
- }
37
-
38
- const PRIORITY_MAP = {
39
- "signal-error": "critical",
40
- "bad-mac": "critical",
41
- "session-corruption": "critical",
42
- "auth-failure": "critical",
43
- "connection-established": "high",
44
- "connection-restored": "high",
45
- "device-paired": "high",
46
- "message-send-error": "normal",
47
- "message-received": "normal",
48
- "scheduled": "low",
49
- "keep-alive": "low"
50
- }
51
-
52
- // ==================== MAIN SOCKET FACTORY ====================
53
- export const makeSocket = (config) => {
54
- const {
55
- waWebSocketUrl, connectTimeoutMs, logger, keepAliveIntervalMs, browser,
56
- auth: authState, printQRInTerminal, defaultQueryTimeoutMs, transactionOpts,
57
- qrTimeout, makeSignalRepository
58
- } = config
59
-
60
- if (printQRInTerminal) {
61
- logger?.warn("printQRInTerminal deprecated")
62
- }
63
-
64
- // ==================== SETUP ====================
65
- // WebSocket URL configuration
66
- const url = typeof waWebSocketUrl === "string" ? new URL(waWebSocketUrl) : waWebSocketUrl
67
- if (config.mobile || url.protocol === "tcp:") {
68
- throw new Boom("Mobile API not supported", { statusCode: DisconnectReason.loggedOut })
69
- }
70
- if (url.protocol === "wss:" && authState?.creds?.routingInfo) {
71
- url.searchParams.append("ED", authState.creds.routingInfo.toString("base64url"))
72
- }
73
-
74
- // Initialize encryption & WebSocket
75
- const ephemeralKeyPair = Curve.generateKeyPair()
76
- const noise = makeNoiseHandler({
77
- keyPair: ephemeralKeyPair,
78
- NOISE_HEADER: NOISE_WA_HEADER,
79
- logger,
80
- routingInfo: authState?.creds?.routingInfo
81
- })
82
- const ws = new WebSocketClient(url, config)
83
-
84
- logger.info({ url: url.toString() }, "Initiating WebSocket connection")
85
- ws.connect()
86
-
87
- // State initialization
88
- const ev = makeEventBuffer(logger)
89
- const { creds } = authState
90
- const keys = addTransactionCapability(authState.keys, logger, transactionOpts)
91
- const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync)
92
- const publicWAMBuffer = new BinaryInfo()
93
- const uqTagId = generateMdTagPrefix()
94
- const sendPromise = promisify(ws.send)
95
-
96
- // ==================== STATE VARIABLES ====================
97
- let epoch = 1
98
- let lastDateRecv
99
- let lastMessageTime = Date.now()
100
- let lastPreKeyCheck = 0
101
- let lastUploadTime = 0
102
- let reconnectAttempts = 0
103
- let consecutiveFailedPings = 0
104
- let closed = false
105
- let isUploadingPreKeys = false
106
- let uploadPreKeysPromise = null
107
- let preKeyCheckQueue = []
108
-
109
- // Timers
110
- let keepAliveReq
111
- let qrTimer
112
- let sessionHealthCheck
113
- let preKeyMonitorInterval
114
- let sessionCleanupInterval
115
-
116
- // ==================== HELPER FUNCTIONS ====================
117
- const generateMessageTag = () => `${uqTagId}${epoch++}`
118
-
119
- const mapWebSocketError = (handler) => {
120
- return (error) => handler(new Boom(`WebSocket Error (${error?.message})`, {
121
- statusCode: getCodeFromWSError(error),
122
- data: error
123
- }))
124
- }
125
-
126
- // ==================== CORE MESSAGING ====================
127
- const sendRawMessage = async (data) => {
128
- if (!ws.isOpen) {
129
- throw new Boom("Connection Closed", { statusCode: DisconnectReason.connectionClosed })
130
- }
131
- const bytes = noise.encodeFrame(data)
132
- await promiseTimeout(connectTimeoutMs, async (resolve, reject) => {
133
- try {
134
- await sendPromise.call(ws, bytes)
135
- resolve()
136
- } catch (error) {
137
- reject(error)
138
- }
139
- })
140
- }
141
-
142
- const sendNode = (frame) => {
143
- if (logger.level === "trace") {
144
- logger.trace({ xml: binaryNodeToString(frame), msg: "xml send" })
145
- }
146
- return sendRawMessage(encodeBinaryNode(frame))
147
- }
148
-
149
- const waitForMessage = async (msgId, timeoutMs = defaultQueryTimeoutMs) => {
150
- let onRecv, onErr
151
- try {
152
- return await promiseTimeout(timeoutMs, (resolve, reject) => {
153
- onRecv = (data) => resolve(data)
154
- onErr = (err) => reject(err || new Boom("Connection Closed", {
155
- statusCode: DisconnectReason.connectionClosed
156
- }))
157
- ws.on(`TAG:${msgId}`, onRecv)
158
- ws.on("close", onErr)
159
- ws.on("error", onErr)
160
- return () => reject(new Boom("Query Cancelled"))
161
- })
162
- } catch (error) {
163
- if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
164
- logger?.warn?.({ msgId }, "Timed out waiting for message")
165
- return undefined
166
- }
167
- throw error
168
- } finally {
169
- if (onRecv) ws.off(`TAG:${msgId}`, onRecv)
170
- if (onErr) {
171
- ws.off("close", onErr)
172
- ws.off("error", onErr)
173
- }
174
- }
175
- }
176
-
177
- const query = async (node, timeoutMs) => {
178
- if (!node.attrs.id) node.attrs.id = generateMessageTag()
179
- const msgId = node.attrs.id
180
-
181
- // Retry logic for rate limiting
182
- for (let i = 0; i < 20; i++) {
183
- try {
184
- const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
185
- const result = waitForMessage(msgId, timeoutMs).catch(reject)
186
- sendNode(node).then(async () => resolve(await result)).catch(reject)
187
- })
188
- if (result && "tag" in result) {
189
- assertNodeErrorFree(result)
190
- }
191
- return result
192
- } catch (error) {
193
- if (error?.data === 429 || error?.isRateLimit) {
194
- await new Promise(r => setTimeout(r, 300 + Math.random() * 700))
195
- continue
196
- }
197
- throw error
198
- }
199
- }
200
- }
201
-
202
- // ==================== USYNC QUERIES ====================
203
- const executeUSyncQuery = async (usyncQuery) => {
204
- if (usyncQuery.protocols.length === 0) {
205
- throw new Boom("USyncQuery must have at least one protocol")
206
- }
207
-
208
- const userNodes = usyncQuery.users.map((user) => ({
209
- tag: "user",
210
- attrs: { jid: !user.phone ? user.id : undefined },
211
- content: usyncQuery.protocols.map((a) => a.getUserElement(user)).filter((a) => a !== null)
212
- }))
213
-
214
- const iq = {
215
- tag: "iq",
216
- attrs: { to: S_WHATSAPP_NET, type: "get", xmlns: "usync" },
217
- content: [{
218
- tag: "usync",
219
- attrs: {
220
- context: usyncQuery.context,
221
- mode: usyncQuery.mode,
222
- sid: generateMessageTag(),
223
- last: "true",
224
- index: "0"
225
- },
226
- content: [
227
- { tag: "query", attrs: {}, content: usyncQuery.protocols.map((a) => a.getQueryElement()) },
228
- { tag: "list", attrs: {}, content: userNodes }
229
- ]
230
- }]
231
- }
232
-
233
- return usyncQuery.parseUSyncQueryResult(await query(iq))
234
- }
235
-
236
- const onWhatsApp = async (...phoneNumber) => {
237
- let usyncQuery = new USyncQuery()
238
- let contactEnabled = false
239
-
240
- for (const jid of phoneNumber) {
241
- if (isLidUser(jid)) {
242
- logger?.warn("LIDs not supported with onWhatsApp")
243
- continue
244
- }
245
- if (!contactEnabled) {
246
- contactEnabled = true
247
- usyncQuery = usyncQuery.withContactProtocol()
248
- }
249
- const phone = `+${jid.replace("+", "").split("@")[0]?.split(":")[0]}`
250
- usyncQuery.withUser(new USyncUser().withPhone(phone))
251
- }
252
-
253
- if (usyncQuery.users.length === 0) return []
254
-
255
- const results = await executeUSyncQuery(usyncQuery)
256
- return results ? results.list
257
- .filter((a) => !!a.contact)
258
- .map(({ contact, id }) => ({ jid: id, exists: contact })) : []
259
- }
260
-
261
- async function pnFromLIDUSync(jids) {
262
- const usyncQuery = new USyncQuery().withLIDProtocol().withContext("background")
263
-
264
- for (const jid of jids) {
265
- if (!isLidUser(jid)) {
266
- usyncQuery.withUser(new USyncUser().withId(jid))
267
- } else {
268
- logger?.warn("LID user found in LID fetch call")
269
- }
270
- }
271
-
272
- if (usyncQuery.users.length === 0) return []
273
-
274
- const results = await executeUSyncQuery(usyncQuery)
275
- return results ? results.list
276
- .filter((a) => !!a.lid)
277
- .map(({ lid, id }) => ({ pn: id, lid })) : []
278
- }
279
-
280
- // ==================== ERROR HANDLING ====================
281
- const onUnexpectedError = (err, msg) => {
282
- logger.error({ err }, `Unexpected error in '${msg}'`)
283
-
284
- const message = (err && ((err.stack || err.message) || String(err))).toLowerCase()
285
-
286
- // Detect MAC errors
287
- if (message.includes('bad mac') || (message.includes('mac') && message.includes('invalid'))) {
288
- logger.warn("Bad MAC error detected - triggering pre-key check")
289
- triggerPreKeyCheck("bad-mac", "critical")
290
- }
291
-
292
- // Detect session corruption
293
- if (message.includes('session') && message.includes('corrupt')) {
294
- logger.warn("Session corruption detected - triggering pre-key check")
295
- triggerPreKeyCheck("session-corruption", "critical")
296
- }
297
- }
298
-
299
- // ==================== AUTH STATE VALIDATION ====================
300
- const validateAuthStateIntegrity = async () => {
301
- try {
302
- logger.info("Validating auth state integrity...")
303
-
304
- const deviceListData = await keys.get("device-list", ["_index"])
305
- const hasDeviceList = !!deviceListData?.['_index']
306
-
307
- const sessionData = await keys.get("session", ["_index"])
308
- const hasSessions = !!sessionData?.['_index']
309
-
310
- const currentPreKeyId = creds.nextPreKeyId - 1
311
- const expectedPreKeys = Math.max(0, currentPreKeyId)
312
- const preKeyThreshold = 20
313
-
314
- logger.info({
315
- hasDeviceList,
316
- hasSessions,
317
- expectedPreKeys,
318
- currentPreKeyId
319
- }, "Auth state integrity check complete")
320
-
321
- if (!hasDeviceList) {
322
- logger.warn("⚠️ Device-list is empty - will rebuild from first messages")
323
- }
324
- if (!hasSessions) {
325
- logger.warn("⚠️ Sessions are empty - will establish as contacts message")
326
- }
327
- } catch (error) {
328
- logger.error({ error }, "Auth state integrity check failed")
329
- }
330
- }
331
-
332
- // ==================== CONNECTION VALIDATION ====================
333
- const validateConnection = async () => {
334
- await validateAuthStateIntegrity()
335
-
336
- let helloMsg = { clientHello: { ephemeral: ephemeralKeyPair.public } }
337
- helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
338
-
339
- logger.info({ browser, helloMsg }, "Connected to WhatsApp")
340
-
341
- const init = proto.HandshakeMessage.encode(helloMsg).finish()
342
- const result = await awaitNextMessage(init)
343
- const handshake = proto.HandshakeMessage.decode(result)
344
-
345
- logger.trace({ handshake }, "Handshake received from WhatsApp")
346
-
347
- const keyEnc = await noise.processHandshake(handshake, creds.noiseKey)
348
- const node = !creds.me
349
- ? generateRegistrationNode(creds, config)
350
- : generateLoginNode(creds.me.id, config)
351
-
352
- logger.info({ node }, !creds.me ? "Attempting registration..." : "Logging in...")
353
-
354
- const payloadEnc = noise.encrypt(proto.ClientPayload.encode(node).finish())
355
- await sendRawMessage(
356
- proto.HandshakeMessage.encode({
357
- clientFinish: { static: keyEnc, payload: payloadEnc }
358
- }).finish()
359
- )
360
-
361
- noise.finishInit()
362
- startKeepAliveRequest()
363
- }
364
-
365
- const awaitNextMessage = async (sendMsg) => {
366
- if (!ws.isOpen) {
367
- throw new Boom("Connection Closed", { statusCode: DisconnectReason.connectionClosed })
368
- }
369
-
370
- let onOpen, onClose
371
- const result = promiseTimeout(connectTimeoutMs, (resolve, reject) => {
372
- onOpen = resolve
373
- onClose = mapWebSocketError(reject)
374
- ws.on("frame", onOpen)
375
- ws.on("close", onClose)
376
- ws.on("error", onClose)
377
- }).finally(() => {
378
- ws.off("frame", onOpen)
379
- ws.off("close", onClose)
380
- ws.off("error", onClose)
381
- })
382
-
383
- if (sendMsg) sendRawMessage(sendMsg).catch(onClose)
384
- return result
385
- }
386
-
387
- const waitForSocketOpen = async () => {
388
- if (ws.isOpen) return
389
- if (ws.isClosed || ws.isClosing) {
390
- throw new Boom("Connection Closed", { statusCode: DisconnectReason.connectionClosed })
391
- }
392
-
393
- let onOpen, onClose
394
- await new Promise((resolve, reject) => {
395
- onOpen = () => resolve(undefined)
396
- onClose = mapWebSocketError(reject)
397
- ws.on("open", onOpen)
398
- ws.on("close", onClose)
399
- ws.on("error", onClose)
400
- }).finally(() => {
401
- ws.off("open", onOpen)
402
- ws.off("close", onClose)
403
- ws.off("error", onClose)
404
- })
405
- }
406
-
407
- // ==================== PRE-KEY MANAGEMENT ====================
408
- const getAvailablePreKeysOnServer = async () => {
409
- const result = await query({
410
- tag: "iq",
411
- attrs: { id: generateMessageTag(), xmlns: "encrypt", type: "get", to: S_WHATSAPP_NET },
412
- content: [{ tag: "count", attrs: {} }]
413
- })
414
- return +getBinaryNodeChild(result, "count").attrs.value
415
- }
416
-
417
- const uploadPreKeys = async (count = MIN_PREKEY_COUNT, retryCount = 0) => {
418
- // Rate limiting
419
- if (retryCount === 0 && Date.now() - lastUploadTime < MIN_UPLOAD_INTERVAL) {
420
- logger.debug(`Skipping upload, only ${Date.now() - lastUploadTime}ms since last`)
421
- return
422
- }
423
-
424
- // Prevent concurrent uploads
425
- if (uploadPreKeysPromise) {
426
- logger.debug("Pre-key upload in progress, waiting...")
427
- await uploadPreKeysPromise
428
- return
429
- }
430
-
431
- const uploadLogic = async () => {
432
- logger.info({ count, retryCount }, "Uploading pre-keys")
433
-
434
- const node = await keys.transaction(async () => {
435
- const { update, node } = await getNextPreKeysNode({ creds, keys }, count)
436
- ev.emit("creds.update", update)
437
- return node
438
- }, creds?.me?.id || "upload-pre-keys")
439
-
440
- try {
441
- await query(node)
442
- logger.info({ count }, "✅ Pre-keys uploaded successfully")
443
- lastUploadTime = Date.now()
444
- } catch (uploadError) {
445
- logger.error({ uploadError: uploadError.toString(), count }, "Failed to upload pre-keys")
446
-
447
- if (retryCount < 3) {
448
- const backoffDelay = Math.min(1000 * Math.pow(2, retryCount), 10000)
449
- logger.info(`Retrying pre-key upload in ${backoffDelay}ms`)
450
- await new Promise((resolve) => setTimeout(resolve, backoffDelay))
451
- return uploadPreKeys(count, retryCount + 1)
452
- }
453
- throw uploadError
454
- }
455
- }
456
-
457
- uploadPreKeysPromise = Promise.race([
458
- uploadLogic(),
459
- new Promise((_, reject) =>
460
- setTimeout(() => reject(new Boom("Pre-key upload timeout", { statusCode: 408 })), UPLOAD_TIMEOUT)
461
- )
462
- ])
463
-
464
- try {
465
- await uploadPreKeysPromise
466
- } finally {
467
- uploadPreKeysPromise = null
468
- }
469
- }
470
-
471
- const smartPreKeyMonitor = async (reason = "scheduled", priority = "normal") => {
472
- const now = Date.now()
473
-
474
- // Skip if checked too recently (unless critical)
475
- if (priority !== "critical" && now - lastPreKeyCheck < CONSTANTS.PREKEY_MIN_INTERVAL) {
476
- logger.debug({ reason }, "Skipping pre-key check - too recent")
477
- return
478
- }
479
-
480
- // Queue critical checks if upload in progress
481
- if (isUploadingPreKeys) {
482
- logger.debug({ reason, priority }, "Pre-key upload in progress")
483
- if (priority === "critical") {
484
- preKeyCheckQueue.push({ reason, priority, timestamp: now })
485
- logger.info("Critical pre-key check queued")
486
- }
487
- return
488
- }
489
-
490
- lastPreKeyCheck = now
491
-
492
- try {
493
- logger.debug({ reason, priority }, "Checking pre-key status")
494
- const preKeyCount = await getAvailablePreKeysOnServer()
495
- logger.info({ preKeyCount, reason, priority }, "Pre-key check result")
496
-
497
- let shouldUpload = false
498
- let uploadCount = 0
499
-
500
- if (preKeyCount <= CONSTANTS.PREKEY_CRITICAL) {
501
- logger.warn({ preKeyCount }, "🚨 CRITICAL: Very low pre-keys!")
502
- shouldUpload = true
503
- uploadCount = INITIAL_PREKEY_COUNT
504
- priority = "critical"
505
- } else if (preKeyCount < MIN_PREKEY_COUNT) {
506
- logger.info({ preKeyCount }, "⚠️ Low pre-keys detected")
507
- shouldUpload = true
508
- uploadCount = Math.max(20, MIN_PREKEY_COUNT - preKeyCount + 5)
509
- } else if (priority === "critical") {
510
- logger.info({ preKeyCount }, "Uploading pre-keys for critical recovery")
511
- shouldUpload = true
512
- uploadCount = 20
513
- } else {
514
- logger.debug({ preKeyCount }, "✅ Pre-key count healthy")
515
- }
516
-
517
- if (shouldUpload) {
518
- isUploadingPreKeys = true
519
- await uploadPreKeys(uploadCount)
520
-
521
- // Process queued checks
522
- if (preKeyCheckQueue.length > 0) {
523
- logger.info(`Processing ${preKeyCheckQueue.length} queued checks`)
524
- preKeyCheckQueue = []
525
- }
526
- }
527
- } catch (error) {
528
- logger.error({ error, reason, priority }, "Pre-key check failed")
529
-
530
- // Retry critical checks
531
- if (priority === "critical") {
532
- setTimeout(() => {
533
- smartPreKeyMonitor(reason, "critical").catch(err =>
534
- logger.error({ err }, "Critical pre-key retry failed")
535
- )
536
- }, 10000)
537
- }
538
- } finally {
539
- isUploadingPreKeys = false
540
- }
541
- }
542
-
543
- const triggerPreKeyCheck = (event, priority = "normal") => {
544
- const effectivePriority = PRIORITY_MAP[event] || priority
545
- logger.debug({ event, priority: effectivePriority }, "Pre-key check triggered")
546
-
547
- smartPreKeyMonitor(event, effectivePriority).catch(err =>
548
- logger.error({ err, event }, "Triggered pre-key check failed")
549
- )
550
- }
551
-
552
- const startPreKeyBackgroundMonitor = () => {
553
- if (preKeyMonitorInterval) clearInterval(preKeyMonitorInterval)
554
-
555
- preKeyMonitorInterval = setInterval(() => {
556
- triggerPreKeyCheck("scheduled", "low")
557
- }, CONSTANTS.PREKEY_CHECK_INTERVAL)
558
-
559
- logger.info(
560
- { intervalMinutes: CONSTANTS.PREKEY_CHECK_INTERVAL / 60000 },
561
- "Pre-key background monitor started"
562
- )
563
- }
564
-
565
- const stopPreKeyBackgroundMonitor = () => {
566
- if (preKeyMonitorInterval) {
567
- clearInterval(preKeyMonitorInterval)
568
- preKeyMonitorInterval = null
569
- logger.debug("Pre-key background monitor stopped")
570
- }
571
- }
572
-
573
- const uploadPreKeysToServerIfRequired = async () => {
574
- try {
575
- const preKeyCount = await getAvailablePreKeysOnServer()
576
- const currentPreKeyId = creds.nextPreKeyId - 1
577
-
578
- logger.info(
579
- `Server: ${preKeyCount} pre-keys, Current ID: ${currentPreKeyId}`
580
- )
581
-
582
- // Only regenerate if server is low
583
- if (preKeyCount < MIN_PREKEY_COUNT) {
584
- const uploadCount = INITIAL_PREKEY_COUNT - preKeyCount
585
- logger.info(
586
- `Server pre-key count low (${preKeyCount}), uploading ${uploadCount} to reach ${INITIAL_PREKEY_COUNT}`
587
- )
588
- await uploadPreKeys(uploadCount)
589
- } else {
590
- logger.info(
591
- `✅ PreKey validation passed - Server: ${preKeyCount} pre-keys`
592
- )
593
- }
594
- } catch (error) {
595
- logger.error({ error }, "Failed to check/upload pre-keys during init")
596
- }
597
- }
598
-
599
- // ==================== SESSION CLEANUP ====================
600
- const cleanupSessionFiles = async () => {
601
- try {
602
- logger.info("Starting session cleanup...")
603
-
604
- const batchData = await keys.get("session", ["_index"])
605
- const sessionBatch = batchData?.['_index'] || {}
606
- const batchSize = Object.keys(sessionBatch).length
607
-
608
- if (batchSize > CONSTANTS.SESSION_MAX_SIZE) {
609
- logger.info(
610
- `Session batch size is ${batchSize}, trimming to ${CONSTANTS.SESSION_MAX_SIZE} most recent`
611
- )
612
-
613
- const sessionKeys = Object.keys(sessionBatch).sort()
614
- const recentSessions = sessionKeys.slice(-CONSTANTS.SESSION_MAX_SIZE)
615
- const trimmedBatch = {}
616
-
617
- recentSessions.forEach(key => {
618
- trimmedBatch[key] = sessionBatch[key]
619
- })
620
-
621
- await keys.set({ "session": { "_index": trimmedBatch } })
622
-
623
- const deleted = batchSize - Object.keys(trimmedBatch).length
624
- logger.info(
625
- `✅ Trimmed session batch: removed ${deleted} old entries (kept ${CONSTANTS.SESSION_MAX_SIZE})`
626
- )
627
- } else {
628
- logger.debug(`Session batch size (${batchSize}) is healthy, no cleanup needed`)
629
- }
630
-
631
- logger.info("Session cleanup complete")
632
- } catch (error) {
633
- logger.error({ error }, "Session cleanup failed")
634
- }
635
- }
636
-
637
- const startSessionCleanup = () => {
638
- if (sessionCleanupInterval) clearInterval(sessionCleanupInterval)
639
-
640
- sessionCleanupInterval = setInterval(() => {
641
- cleanupSessionFiles().catch(err =>
642
- logger.error({ err }, "Scheduled session cleanup failed")
643
- )
644
- }, CONSTANTS.SESSION_CLEANUP_INTERVAL)
645
-
646
- logger.info("Session cleanup started (every 10 minutes)")
647
- }
648
-
649
- const stopSessionCleanup = () => {
650
- if (sessionCleanupInterval) {
651
- clearInterval(sessionCleanupInterval)
652
- sessionCleanupInterval = null
653
- logger.debug("Session cleanup stopped")
654
- }
655
- }
656
-
657
- // ==================== MESSAGE HANDLING ====================
658
- const onMessageReceived = (data) => {
659
- noise.decodeFrame(data, (frame) => {
660
- lastDateRecv = new Date()
661
- lastMessageTime = Date.now()
662
- reconnectAttempts = 0
663
-
664
- let anyTriggered = ws.emit("frame", frame)
665
-
666
- if (!(frame instanceof Uint8Array)) {
667
- const msgId = frame.attrs.id
668
-
669
- if (logger.level === "trace") {
670
- logger.trace({ xml: binaryNodeToString(frame), msg: "recv xml" })
671
- }
672
-
673
- anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered
674
-
675
- const l0 = frame.tag
676
- const l1 = frame.attrs || {}
677
- const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ""
678
-
679
- for (const key of Object.keys(l1)) {
680
- anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
681
- anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
682
- anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
683
- }
684
-
685
- anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
686
- anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
687
-
688
- if (!anyTriggered && logger.level === "debug") {
689
- logger.debug({ unhandled: true, msgId, fromMe: false, frame }, "Unhandled communication received")
690
- }
691
- }
692
- })
693
- }
694
-
695
- // ==================== CONNECTION LIFECYCLE ====================
696
- const end = (error) => {
697
- if (closed) {
698
- logger.trace({ trace: error?.stack }, "Connection already closed")
699
- return
700
- }
701
-
702
- closed = true
703
-
704
- const shouldLogError = error && error.message !== "Connection Terminated"
705
- if (shouldLogError) {
706
- logger.info({ trace: error?.stack }, "Connection errored")
707
- } else {
708
- logger.debug("Connection closed gracefully")
709
- }
710
-
711
- // Cleanup timers
712
- clearInterval(keepAliveReq)
713
- clearInterval(sessionHealthCheck)
714
- clearTimeout(qrTimer)
715
- stopPreKeyBackgroundMonitor()
716
- stopSessionCleanup()
717
-
718
- // Cleanup listeners
719
- ws.removeAllListeners("close")
720
- ws.removeAllListeners("open")
721
- ws.removeAllListeners("message")
722
-
723
- // Close WebSocket
724
- if (!ws.isClosed && !ws.isClosing) {
725
- try {
726
- ws.close()
727
- } catch { }
728
- }
729
-
730
- // Emit connection update
731
- if (shouldLogError || (error && error.output?.statusCode !== DisconnectReason.connectionClosed)) {
732
- ev.emit("connection.update", {
733
- connection: "close",
734
- lastDisconnect: { error, date: new Date() }
735
- })
736
- }
737
-
738
- ev.removeAllListeners("connection.update")
739
- }
740
-
741
- const attemptReconnection = async (reason = "unknown") => {
742
- if (closed) {
743
- logger.debug("Cannot reconnect - connection already closed")
744
- return
745
- }
746
-
747
- if (reconnectAttempts >= CONSTANTS.MAX_RECONNECT) {
748
- logger.error(
749
- { attempts: reconnectAttempts, maxAttempts: CONSTANTS.MAX_RECONNECT },
750
- "Max reconnection attempts reached"
751
- )
752
- end(new Boom("Connection Lost", { statusCode: DisconnectReason.connectionLost }))
753
- return
754
- }
755
-
756
- reconnectAttempts++
757
-
758
- // Longer delays for network issues
759
- const isNetworkIssue = reason === "websocket-close"
760
- const baseDelay = isNetworkIssue ? 2000 : 1000
761
- const backoffDelay = Math.min(baseDelay * Math.pow(2, reconnectAttempts - 1), 30000)
762
-
763
- logger.info({
764
- attempt: reconnectAttempts,
765
- maxAttempts: CONSTANTS.MAX_RECONNECT,
766
- delay: backoffDelay,
767
- reason,
768
- isNetworkIssue
769
- }, "Attempting WebSocket reconnection")
770
-
771
- try {
772
- await new Promise(resolve => setTimeout(resolve, backoffDelay))
773
- logger.debug("Restarting WebSocket connection")
774
- await ws.restart()
775
- logger.info("✅ WebSocket reconnected successfully")
776
- reconnectAttempts = 0
777
- } catch (err) {
778
- logger.error({ err, attempt: reconnectAttempts }, "Reconnection attempt failed")
779
-
780
- if (reconnectAttempts < CONSTANTS.MAX_RECONNECT) {
781
- return attemptReconnection(reason)
782
- } else {
783
- end(new Boom("Failed to reconnect", { statusCode: DisconnectReason.connectionLost }))
784
- }
785
- }
786
- }
787
-
788
- // ==================== KEEP-ALIVE & HEALTH ====================
789
- const startKeepAliveRequest = () => {
790
- keepAliveReq = setInterval(async () => {
791
- if (!lastDateRecv) lastDateRecv = new Date()
792
-
793
- if (ws.isOpen) {
794
- try {
795
- await query({
796
- tag: "iq",
797
- attrs: { id: generateMessageTag(), to: S_WHATSAPP_NET, type: "get", xmlns: "w:p" },
798
- content: [{ tag: "ping", attrs: {} }]
799
- })
800
- consecutiveFailedPings = 0
801
- logger.trace("Keep-alive ping successful")
802
- } catch (err) {
803
- consecutiveFailedPings++
804
- logger.warn(
805
- { consecutiveFailures: consecutiveFailedPings, maxAllowed: CONSTANTS.MAX_FAILED_PINGS },
806
- "Keep-alive ping failed"
807
- )
808
-
809
- if (consecutiveFailedPings >= CONSTANTS.MAX_FAILED_PINGS) {
810
- logger.error("Multiple consecutive ping failures - connection lost")
811
- end(new Boom("Connection was lost", { statusCode: DisconnectReason.connectionLost }))
812
- }
813
- }
814
- } else {
815
- logger.warn("Keep-alive called when WebSocket not open - triggering reconnection")
816
-
817
- if (!closed && ws.isClosed) {
818
- ws.restart().catch(err => {
819
- logger.error({ err }, "Failed to restart WebSocket from keep-alive")
820
- end(new Boom("Connection Lost", { statusCode: DisconnectReason.connectionLost }))
821
- })
822
- }
823
- }
824
- }, keepAliveIntervalMs)
825
- }
826
-
827
- const startSessionHealthMonitor = () => {
828
- sessionHealthCheck = setInterval(() => {
829
- const timeSinceLastMsg = Date.now() - lastMessageTime
830
- const healthCheckIntervalMs = keepAliveIntervalMs * CONSTANTS.HEALTH_CHECK_MULTIPLIER
831
-
832
- if (timeSinceLastMsg > healthCheckIntervalMs) {
833
- if (ws.isOpen) {
834
- logger.warn(
835
- { timeSinceLastMsg, threshold: healthCheckIntervalMs },
836
- "Extended inactivity detected"
837
- )
838
- } else {
839
- logger.error(
840
- { timeSinceLastMsg },
841
- "WebSocket closed during extended inactivity - reconnecting"
842
- )
843
- attemptReconnection("health-check-failed").catch(err =>
844
- logger.error({ err }, "Health check reconnection failed")
845
- )
846
- }
847
- }
848
- }, keepAliveIntervalMs * CONSTANTS.HEALTH_CHECK_MULTIPLIER)
849
- }
850
-
851
- // ==================== UTILITY FUNCTIONS ====================
852
- const sendPassiveIq = (tag) => query({
853
- tag: "iq",
854
- attrs: { to: S_WHATSAPP_NET, xmlns: "passive", type: "set" },
855
- content: [{ tag, attrs: {} }]
856
- })
857
-
858
- const logout = async (msg) => {
859
- const jid = authState.creds.me?.id
860
- if (jid) {
861
- await sendNode({
862
- tag: "iq",
863
- attrs: { to: S_WHATSAPP_NET, type: "set", id: generateMessageTag(), xmlns: "md" },
864
- content: [{ tag: "remove-companion-device", attrs: { jid, reason: "user_initiated" } }]
865
- })
866
- }
867
- end(new Boom(msg || "Intentional Logout", { statusCode: DisconnectReason.loggedOut }))
868
- }
869
-
870
- const requestPairingCode = async (phoneNumber, customPairingCode) => {
871
- const pairingCode = customPairingCode ?? bytesToCrockford(randomBytes(5))
872
-
873
- if (customPairingCode && customPairingCode?.length !== 8) {
874
- throw new Error("Custom pairing code must be exactly 8 chars")
875
- }
876
-
877
- authState.creds.pairingCode = pairingCode
878
- authState.creds.me = { id: jidEncode(phoneNumber, "s.whatsapp.net"), name: "~" }
879
- ev.emit("creds.update", authState.creds)
880
-
881
- await sendNode({
882
- tag: "iq",
883
- attrs: { to: S_WHATSAPP_NET, type: "set", id: generateMessageTag(), xmlns: "md" },
884
- content: [{
885
- tag: "link_code_companion_reg",
886
- attrs: {
887
- jid: authState.creds.me.id,
888
- stage: "companion_hello",
889
- should_show_push_notification: "true"
890
- },
891
- content: [
892
- { tag: "link_code_pairing_wrapped_companion_ephemeral_pub", attrs: {}, content: await generatePairingKey() },
893
- { tag: "companion_server_auth_key_pub", attrs: {}, content: authState.creds.noiseKey.public },
894
- { tag: "companion_platform_id", attrs: {}, content: getPlatformId(browser[1]) },
895
- { tag: "companion_platform_display", attrs: {}, content: `${browser[1]} (${browser[0]})` },
896
- { tag: "link_code_pairing_nonce", attrs: {}, content: "0" }
897
- ]
898
- }]
899
- })
900
-
901
- return authState.creds.pairingCode
902
- }
903
-
904
- async function generatePairingKey() {
905
- const salt = randomBytes(32)
906
- const randomIv = randomBytes(16)
907
- const key = await derivePairingCodeKey(authState.creds.pairingCode, salt)
908
- const ciphered = aesEncryptCTR(authState.creds.pairingEphemeralKeyPair.public, key, randomIv)
909
- return Buffer.concat([salt, randomIv, ciphered])
910
- }
911
-
912
- const sendWAMBuffer = (wamBuffer) => query({
913
- tag: "iq",
914
- attrs: { to: S_WHATSAPP_NET, id: generateMessageTag(), xmlns: "w:stats" },
915
- content: [{ tag: "add", attrs: { t: Math.round(Date.now() / 1000) + "" }, content: wamBuffer }]
916
- })
917
-
918
- // ==================== WEBSOCKET EVENT HANDLERS ====================
919
- ws.on("message", onMessageReceived)
920
-
921
- ws.on("open", async () => {
922
- try {
923
- await validateConnection()
924
- } catch (err) {
925
- logger.error({ err }, "Error in validating connection")
926
- end(err)
927
- }
928
- })
929
-
930
- ws.on("error", (err) => {
931
- const isNetworkTimeout = err?.code === "ETIMEDOUT" || err?.code === "ECONNREFUSED"
932
- const isNetworkError = err?.code === "ENOTFOUND" || err?.code === "ECONNRESET" || isNetworkTimeout
933
-
934
- const errorDetails = {
935
- message: err?.message || "Unknown error",
936
- code: err?.code,
937
- isNetworkError,
938
- stack: err?.stack
939
- }
940
-
941
- if (isNetworkTimeout) {
942
- logger.warn(errorDetails, "WebSocket connection timeout - network may be unreachable")
943
- } else if (isNetworkError) {
944
- logger.warn(errorDetails, "WebSocket network error - will attempt reconnection")
945
- } else {
946
- logger.warn(errorDetails, "WebSocket error occurred")
947
- }
948
-
949
- // Trigger reconnection on critical errors
950
- if (isNetworkError && !closed) {
951
- attemptReconnection("websocket-error").catch(err => {
952
- logger.error({ err }, "Reconnection attempt failed after WebSocket error")
953
- })
954
- }
955
- })
956
-
957
- ws.on("close", (code, reason) => {
958
- const closeReason = reason?.toString() || "Unknown"
959
- logger.debug({ code, reason: closeReason }, "WebSocket closed")
960
-
961
- if (!closed) {
962
- const delayMs = code === 1000 ? 1000 : 2000
963
- setTimeout(() => {
964
- attemptReconnection("websocket-close").catch(err => {
965
- logger.error({ err, code, reason: closeReason }, "Reconnection failed")
966
- end(new Boom("Connection Terminated", { statusCode: DisconnectReason.connectionClosed }))
967
- })
968
- }, delayMs)
969
- }
970
- })
971
-
972
- ws.on("CB:xmlstreamend", () => {
973
- logger.info("Stream ended by server")
974
- if (!closed) {
975
- end(new Boom("Connection Terminated by Server", { statusCode: DisconnectReason.connectionClosed }))
976
- }
977
- })
978
-
979
- // ==================== PAIRING HANDLERS ====================
980
- ws.on("CB:iq,type:set,pair-device", async (stanza) => {
981
- await sendNode({
982
- tag: "iq",
983
- attrs: { to: S_WHATSAPP_NET, type: "result", id: stanza.attrs.id }
984
- })
985
-
986
- const pairDeviceNode = getBinaryNodeChild(stanza, "pair-device")
987
- const refNodes = getBinaryNodeChildren(pairDeviceNode, "ref")
988
- const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString("base64")
989
- const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString("base64")
990
- const advB64 = creds.advSecretKey
991
-
992
- let qrMs = qrTimeout || 60000
993
-
994
- const genPairQR = () => {
995
- if (!ws.isOpen) return
996
-
997
- const refNode = refNodes.shift()
998
- if (!refNode) {
999
- end(new Boom("QR refs attempts ended", { statusCode: DisconnectReason.timedOut }))
1000
- return
1001
- }
1002
-
1003
- const ref = refNode.content.toString("utf-8")
1004
- const qr = [ref, noiseKeyB64, identityKeyB64, advB64].join(",")
1005
- ev.emit("connection.update", { qr })
1006
-
1007
- qrTimer = setTimeout(genPairQR, qrMs)
1008
- qrMs = qrTimeout || 20000
1009
- }
1010
-
1011
- genPairQR()
1012
- })
1013
-
1014
- ws.on("CB:iq,,pair-success", async (stanza) => {
1015
- logger.debug("Pair success received")
1016
-
1017
- try {
1018
- const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
1019
- logger.info(
1020
- { me: updatedCreds.me, platform: updatedCreds.platform },
1021
- "Pairing configured successfully"
1022
- )
1023
-
1024
- ev.emit("creds.update", updatedCreds)
1025
- ev.emit("connection.update", { isNewLogin: true, qr: undefined })
1026
- triggerPreKeyCheck("device-paired", "high")
1027
-
1028
- await sendNode(reply)
1029
- } catch (error) {
1030
- logger.info({ trace: error.stack }, "Error in pairing")
1031
- end(error)
1032
- }
1033
- })
1034
-
1035
- // ==================== CONNECTION SUCCESS ====================
1036
- ws.on("CB:success", async (node) => {
1037
- try {
1038
- await uploadPreKeysToServerIfRequired()
1039
- await sendPassiveIq("active")
1040
- } catch (err) {
1041
- logger.warn({ err }, "Failed to send initial passive IQ")
1042
- }
1043
-
1044
- logger.info("✅ Opened connection to WhatsApp")
1045
- clearTimeout(qrTimer)
1046
-
1047
- triggerPreKeyCheck("connection-established", "high")
1048
- startPreKeyBackgroundMonitor()
1049
- startSessionCleanup()
1050
-
1051
- ev.emit("creds.update", { me: { ...authState.creds.me, lid: node.attrs.lid } })
1052
- ev.emit("connection.update", { connection: "open" })
1053
-
1054
- startSessionHealthMonitor()
1055
- reconnectAttempts = 0
1056
-
1057
- // Handle LID session creation
1058
- if (node.attrs.lid && authState.creds.me?.id) {
1059
- const myLID = node.attrs.lid
1060
- process.nextTick(async () => {
1061
- try {
1062
- const myPN = authState.creds.me.id
1063
- await signalRepository.lidMapping.storeLIDPNMappings([{ lid: myLID, pn: myPN }])
1064
-
1065
- const { user, device } = jidDecode(myPN)
1066
- const existingData = await authState.keys.get("device-list", ["_index"])
1067
- const currentBatch = existingData?.['_index'] || {}
1068
- currentBatch[user] = [device?.toString() || "0"]
1069
-
1070
- // Enforce batch size limit
1071
- const deviceKeys = Object.keys(currentBatch).filter(k => k !== '_index')
1072
- if (deviceKeys.length > BATCH_SIZE) {
1073
- deviceKeys.sort()
1074
- const toRemove = deviceKeys.slice(0, deviceKeys.length - BATCH_SIZE)
1075
- toRemove.forEach(k => delete currentBatch[k])
1076
- logger.debug(`Cleaned up ${toRemove.length} old device-list entries (kept ${BATCH_SIZE})`)
1077
- }
1078
-
1079
- await authState.keys.set({ "device-list": { "_index": currentBatch } })
1080
- await signalRepository.migrateSession(myPN, myLID)
1081
-
1082
- logger.info({ myPN, myLID }, "Own LID session created successfully")
1083
- } catch (error) {
1084
- logger.error({ error, lid: myLID }, "Failed to create own LID session")
1085
- }
1086
- })
1087
- }
1088
- })
1089
-
1090
- // ==================== ERROR HANDLERS ====================
1091
- ws.on('CB:stream:error', (node) => {
1092
- logger.error({ node }, 'Stream errored out')
1093
- const { reason, statusCode } = getErrorCodeFromStreamError(node)
1094
- end(new Boom(`Stream Errored (${reason})`, { statusCode, data: node }))
1095
-
1096
- if (statusCode === 500 || statusCode === 440) {
1097
- logger.debug("Triggering background pre-key check after stream error")
1098
- triggerPreKeyCheck("stream-error-recovery", "normal")
1099
- }
1100
- })
1101
-
1102
- ws.on("CB:failure", (node) => {
1103
- const reason = +(node.attrs.reason || 500)
1104
- end(new Boom("Connection Failure", { statusCode: reason, data: node.attrs }))
1105
- })
1106
-
1107
- ws.on("CB:ib,,downgrade_webclient", () => {
1108
- end(new Boom("Multi-device beta not joined", { statusCode: DisconnectReason.multideviceMismatch }))
1109
- })
1110
-
1111
- ws.on("CB:ib,,offline_preview", (node) => {
1112
- logger.info("Offline preview received", JSON.stringify(node))
1113
- sendNode({
1114
- tag: "ib",
1115
- attrs: {},
1116
- content: [{ tag: "offline_batch", attrs: { count: "100" } }]
1117
- })
1118
- })
1119
-
1120
- ws.on("CB:ib,,edge_routing", (node) => {
1121
- const edgeRoutingNode = getBinaryNodeChild(node, "edge_routing")
1122
- const routingInfo = getBinaryNodeChild(edgeRoutingNode, "routing_info")
1123
-
1124
- if (routingInfo?.content) {
1125
- authState.creds.routingInfo = Buffer.from(routingInfo?.content)
1126
- ev.emit("creds.update", authState.creds)
1127
- }
1128
- })
1129
-
1130
- // ==================== OFFLINE NOTIFICATIONS ====================
1131
- let didStartBuffer = false
1132
- process.nextTick(() => {
1133
- if (creds.me?.id) {
1134
- ev.buffer()
1135
- didStartBuffer = true
1136
- }
1137
- ev.emit("connection.update", {
1138
- connection: "connecting",
1139
- receivedPendingNotifications: false,
1140
- qr: undefined
1141
- })
1142
- })
1143
-
1144
- ws.on("CB:ib,,offline", (node) => {
1145
- const child = getBinaryNodeChild(node, "offline")
1146
- const offlineNotifs = +(child?.attrs.count || 0)
1147
- logger.info(`Handled ${offlineNotifs} offline messages/notifications`)
1148
-
1149
- if (didStartBuffer) {
1150
- ev.flush()
1151
- logger.trace("Flushed events for initial buffer")
1152
- }
1153
-
1154
- ev.emit("connection.update", { receivedPendingNotifications: true })
1155
- })
1156
-
1157
- // ==================== CREDENTIALS UPDATE ====================
1158
- ev.on("creds.update", (update) => {
1159
- const name = update.me?.name
1160
- if (creds.me?.name !== name) {
1161
- logger.debug({ name }, "Updated pushName")
1162
- sendNode({ tag: "presence", attrs: { name } }).catch((err) =>
1163
- logger.warn({ trace: err.stack }, "Error in sending presence update on name change")
1164
- )
1165
- }
1166
- Object.assign(creds, update)
1167
- })
1168
-
1169
- // ==================== RETURN SOCKET API ====================
1170
- return {
1171
- type: "md",
1172
- ws,
1173
- ev,
1174
- authState: { creds, keys },
1175
- signalRepository,
1176
- get user() {
1177
- return authState.creds.me
1178
- },
1179
- generateMessageTag,
1180
- query,
1181
- waitForMessage,
1182
- waitForSocketOpen,
1183
- sendRawMessage,
1184
- sendNode,
1185
- logout,
1186
- end,
1187
- onUnexpectedError,
1188
- uploadPreKeys,
1189
- uploadPreKeysToServerIfRequired,
1190
- requestPairingCode,
1191
- wamBuffer: publicWAMBuffer,
1192
- waitForConnectionUpdate: bindWaitForConnectionUpdate(ev),
1193
- sendWAMBuffer,
1194
- executeUSyncQuery,
1195
- onWhatsApp,
1196
- listener: (eventName) => {
1197
- if (typeof ev.listenerCount === "function") return ev.listenerCount(eventName)
1198
- if (typeof ev.listener === "function") return ev.listener(eventName)?.length || 0
1199
- return 0
1200
- }
1201
- }
1
+ import { Boom } from "@hapi/boom"
2
+ import { randomBytes } from "crypto"
3
+ import { URL } from "url"
4
+ import { promisify } from "util"
5
+ import { proto } from "../../WAProto/index.js"
6
+ import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MIN_UPLOAD_INTERVAL, NOISE_WA_HEADER, UPLOAD_TIMEOUT, BATCH_SIZE, TimeMs } from "../Defaults/index.js"
7
+ import { DisconnectReason } from "../Types/index.js"
8
+ import { addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, bytesToCrockford, configureSuccessfulPairing, Curve, derivePairingCodeKey, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, makeEventBuffer, makeNoiseHandler, promiseTimeout, signedKeyPair, xmppSignedPreKey } from "../Utils/index.js"
9
+ import { getPlatformId, migrateIndexKey } from "../Utils/index.js"
10
+ import { assertNodeErrorFree, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidDecode, jidEncode, S_WHATSAPP_NET } from "../WABinary/index.js"
11
+ import { BinaryInfo } from "../WAM/BinaryInfo.js"
12
+ import { USyncQuery, USyncUser } from "../WAUSync/index.js"
13
+ import { WebSocketClient } from "./Client/index.js"
14
+
15
+ // ─── Module-scope helpers ──────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Map a raw WebSocket error into a Boom so callers
19
+ * can inspect the statusCode / DisconnectReason.
20
+ */
21
+ const mapWebSocketError = (handler) => (error) =>
22
+ handler(
23
+ new Boom(`WebSocket Error (${error?.message})`, {
24
+ statusCode: getCodeFromWSError(error),
25
+ data: error
26
+ })
27
+ )
28
+
29
+ // ─── Factory ──────────────────────────────────────────────────────────────────
30
+
31
+ export const makeSocket = (config) => {
32
+ const {
33
+ waWebSocketUrl,
34
+ connectTimeoutMs,
35
+ logger,
36
+ keepAliveIntervalMs,
37
+ browser,
38
+ auth: authState,
39
+ printQRInTerminal,
40
+ defaultQueryTimeoutMs,
41
+ transactionOpts,
42
+ qrTimeout,
43
+ makeSignalRepository
44
+ } = config
45
+
46
+ if (printQRInTerminal) logger?.warn("printQRInTerminal deprecated")
47
+
48
+ const url = typeof waWebSocketUrl === 'string' ? new URL(waWebSocketUrl) : waWebSocketUrl
49
+
50
+ if (config.mobile || url.protocol === 'tcp:')
51
+ throw new Boom('Mobile API not supported', { statusCode: DisconnectReason.loggedOut })
52
+
53
+ if (url.protocol === 'wss:' && authState?.creds?.routingInfo)
54
+ url.searchParams.append('ED', authState.creds.routingInfo.toString('base64url'))
55
+
56
+ const ephemeralKeyPair = Curve.generateKeyPair()
57
+ const noise = makeNoiseHandler({
58
+ keyPair: ephemeralKeyPair,
59
+ NOISE_HEADER: NOISE_WA_HEADER,
60
+ logger,
61
+ routingInfo: authState?.creds?.routingInfo
62
+ })
63
+
64
+ const ws = new WebSocketClient(url, config)
65
+ logger.info({ url: url.toString() }, 'Initiating WebSocket connection')
66
+ ws.connect()
67
+
68
+ const ev = makeEventBuffer(logger)
69
+ const { creds } = authState
70
+ const keys = addTransactionCapability(authState.keys, logger, transactionOpts)
71
+ const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync)
72
+ const publicWAMBuffer = new BinaryInfo()
73
+ const uqTagId = generateMdTagPrefix()
74
+ const sendPromise = promisify(ws.send)
75
+
76
+ let epoch = 1
77
+ let lastDateRecv
78
+ let lastUploadTime = 0
79
+ let uploadPreKeysPromise = null
80
+ let closed = false
81
+ let keepAliveReq
82
+ let qrTimer
83
+ let serverTimeOffsetMs = 0
84
+
85
+ const generateMessageTag = () => `${uqTagId}${epoch++}`
86
+
87
+ // ─── Transport ──────────────────────────────────────────────────────────────
88
+
89
+ const sendRawMessage = async (data) => {
90
+ if (!ws.isOpen)
91
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
92
+ const bytes = noise.encodeFrame(data)
93
+ await promiseTimeout(connectTimeoutMs, async (resolve, reject) => {
94
+ try { await sendPromise.call(ws, bytes); resolve() }
95
+ catch (error) { reject(error) }
96
+ })
97
+ }
98
+
99
+ const sendNode = (frame) => {
100
+ if (logger.level === 'trace') logger.trace({ xml: binaryNodeToString(frame), msg: 'xml send' })
101
+ return sendRawMessage(encodeBinaryNode(frame))
102
+ }
103
+
104
+ // ─── Query / messaging ──────────────────────────────────────────────────────
105
+
106
+ const waitForMessage = async (msgId, timeoutMs = defaultQueryTimeoutMs) => {
107
+ let onRecv, onErr
108
+ try {
109
+ return await promiseTimeout(timeoutMs, (resolve, reject) => {
110
+ onRecv = (data) => resolve(data)
111
+ onErr = (err) =>
112
+ reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
113
+ ws.on(`TAG:${msgId}`, onRecv)
114
+ ws.on('close', onErr)
115
+ ws.on('error', onErr)
116
+ return () => reject(new Boom('Query Cancelled'))
117
+ })
118
+ } catch (error) {
119
+ if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
120
+ logger?.warn?.({ msgId }, 'timed out waiting for message')
121
+ return undefined
122
+ }
123
+ throw error
124
+ } finally {
125
+ if (onRecv) ws.off(`TAG:${msgId}`, onRecv)
126
+ if (onErr) { ws.off('close', onErr); ws.off('error', onErr) }
127
+ }
128
+ }
129
+
130
+ const query = async (node, timeoutMs) => {
131
+ if (!node.attrs.id) node.attrs.id = generateMessageTag()
132
+ const msgId = node.attrs.id
133
+ const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
134
+ const result = waitForMessage(msgId, timeoutMs).catch(reject)
135
+ sendNode(node).then(async () => resolve(await result)).catch(reject)
136
+ })
137
+ if (result && 'tag' in result) assertNodeErrorFree(result)
138
+ return result
139
+ }
140
+
141
+ // ─── USync ──────────────────────────────────────────────────────────────────
142
+
143
+ const executeUSyncQuery = async (usyncQuery) => {
144
+ if (usyncQuery.protocols.length === 0)
145
+ throw new Boom('USyncQuery must have at least one protocol')
146
+ const userNodes = usyncQuery.users.map((user) => ({
147
+ tag: 'user',
148
+ attrs: { jid: !user.phone ? user.id : undefined },
149
+ content: usyncQuery.protocols.map((a) => a.getUserElement(user)).filter((a) => a !== null)
150
+ }))
151
+ const iq = {
152
+ tag: 'iq',
153
+ attrs: { to: S_WHATSAPP_NET, type: 'get', xmlns: 'usync' },
154
+ content: [{
155
+ tag: 'usync',
156
+ attrs: { context: usyncQuery.context, mode: usyncQuery.mode, sid: generateMessageTag(), last: 'true', index: '0' },
157
+ content: [
158
+ { tag: 'query', attrs: {}, content: usyncQuery.protocols.map((a) => a.getQueryElement()) },
159
+ { tag: 'list', attrs: {}, content: userNodes }
160
+ ]
161
+ }]
162
+ }
163
+ return usyncQuery.parseUSyncQueryResult(await query(iq))
164
+ }
165
+
166
+ async function pnFromLIDUSync(jids) {
167
+ const usyncQuery = new USyncQuery().withLIDProtocol().withContext('background')
168
+ for (const jid of jids) {
169
+ if (!isLidUser(jid)) usyncQuery.withUser(new USyncUser().withId(jid))
170
+ else logger?.warn('LID user found in LID fetch call')
171
+ }
172
+ if (usyncQuery.users.length === 0) return []
173
+ const results = await executeUSyncQuery(usyncQuery)
174
+ return results ? results.list.filter((a) => !!a.lid).map(({ lid, id }) => ({ pn: id, lid })) : []
175
+ }
176
+
177
+ // ─── Pre-keys ───────────────────────────────────────────────────────────────
178
+
179
+ const getAvailablePreKeysOnServer = async () => {
180
+ const result = await query({
181
+ tag: 'iq',
182
+ attrs: { id: generateMessageTag(), xmlns: 'encrypt', type: 'get', to: S_WHATSAPP_NET },
183
+ content: [{ tag: 'count', attrs: {} }]
184
+ })
185
+ return +getBinaryNodeChild(result, 'count').attrs.value
186
+ }
187
+
188
+ const uploadPreKeys = async (count = MIN_PREKEY_COUNT, retryCount = 0) => {
189
+ if (retryCount === 0 && Date.now() - lastUploadTime < MIN_UPLOAD_INTERVAL) {
190
+ logger.debug(`Skipping upload, only ${Date.now() - lastUploadTime}ms since last`)
191
+ return
192
+ }
193
+ if (uploadPreKeysPromise) {
194
+ logger.debug('Pre-key upload in progress, waiting...')
195
+ await uploadPreKeysPromise
196
+ return
197
+ }
198
+ const uploadLogic = async () => {
199
+ logger.info({ count, retryCount }, 'Uploading pre-keys')
200
+ const node = await keys.transaction(async () => {
201
+ const { update, node } = await getNextPreKeysNode({ creds, keys }, count)
202
+ ev.emit('creds.update', update)
203
+ return node
204
+ }, creds?.me?.id || 'upload-pre-keys')
205
+ try {
206
+ await query(node)
207
+ logger.info({ count }, '✅ Pre-keys uploaded successfully')
208
+ lastUploadTime = Date.now()
209
+ } catch (uploadError) {
210
+ logger.error({ uploadError: uploadError.toString(), count }, 'Failed to upload pre-keys')
211
+ if (retryCount < 3) {
212
+ const backoffDelay = Math.min(1000 * Math.pow(2, retryCount), 10000)
213
+ logger.info(`Retrying pre-key upload in ${backoffDelay}ms`)
214
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay))
215
+ return uploadPreKeys(count, retryCount + 1)
216
+ }
217
+ throw uploadError
218
+ }
219
+ }
220
+ uploadPreKeysPromise = Promise.race([
221
+ uploadLogic(),
222
+ new Promise((_, reject) =>
223
+ setTimeout(() => reject(new Boom('Pre-key upload timeout', { statusCode: 408 })), UPLOAD_TIMEOUT)
224
+ )
225
+ ])
226
+ try { await uploadPreKeysPromise } finally { uploadPreKeysPromise = null }
227
+ }
228
+
229
+ const uploadPreKeysToServerIfRequired = async () => {
230
+ try {
231
+ const preKeyCount = await getAvailablePreKeysOnServer()
232
+ logger.info(`${preKeyCount} pre-keys found on server`)
233
+ if (preKeyCount < MIN_PREKEY_COUNT) {
234
+ const uploadCount = INITIAL_PREKEY_COUNT - preKeyCount
235
+ logger.info(`Server pre-key count low (${preKeyCount}), uploading ${uploadCount}`)
236
+ await uploadPreKeys(uploadCount)
237
+ } else {
238
+ logger.info(`✅ PreKey validation passed - Server: ${preKeyCount} pre-keys`)
239
+ }
240
+ } catch (error) {
241
+ logger.error({ error }, 'Failed to check/upload pre-keys during init')
242
+ }
243
+ }
244
+
245
+ // ─── Key-bundle digest & signed pre-key rotation ────────────────────────────
246
+
247
+ /**
248
+ * Validate our current key-bundle against the server.
249
+ * If the server returns no digest node our keys are out of sync —
250
+ * force a pre-key upload and surface the error so the caller can decide.
251
+ */
252
+ const digestKeyBundle = async () => {
253
+ const res = await query({
254
+ tag: 'iq',
255
+ attrs: { to: S_WHATSAPP_NET, type: 'get', xmlns: 'encrypt' },
256
+ content: [{ tag: 'digest', attrs: {} }]
257
+ })
258
+ const digestNode = getBinaryNodeChild(res, 'digest')
259
+ if (!digestNode) {
260
+ await uploadPreKeys()
261
+ throw new Error('encrypt/get digest returned no digest node')
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Rotate our signed pre-key on the server.
267
+ * Should be called periodically (e.g. every 7 days) to keep sessions healthy.
268
+ */
269
+ const rotateSignedPreKey = async () => {
270
+ const newId = (creds.signedPreKey.keyId || 0) + 1
271
+ const skey = await signedKeyPair(creds.signedIdentityKey, newId)
272
+ await query({
273
+ tag: 'iq',
274
+ attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'encrypt' },
275
+ content: [{ tag: 'rotate', attrs: {}, content: [xmppSignedPreKey(skey)] }]
276
+ })
277
+ ev.emit('creds.update', { signedPreKey: skey })
278
+ }
279
+
280
+ // ─── Server time offset ─────────────────────────────────────────────────────
281
+
282
+ const updateServerTimeOffset = ({ attrs }) => {
283
+ const tValue = attrs?.t
284
+ if (!tValue) return
285
+ const parsed = Number(tValue)
286
+ if (Number.isNaN(parsed) || parsed <= 0) return
287
+ serverTimeOffsetMs = parsed * 1000 - Date.now()
288
+ logger.debug({ offset: serverTimeOffsetMs }, 'calculated server time offset')
289
+ }
290
+
291
+ // ─── Unified session telemetry ───────────────────────────────────────────────
292
+
293
+ const getUnifiedSessionId = () => {
294
+ const offsetMs = 3 * TimeMs.Day
295
+ const now = Date.now() + serverTimeOffsetMs
296
+ return ((now + offsetMs) % TimeMs.Week).toString()
297
+ }
298
+
299
+ const sendUnifiedSession = async () => {
300
+ if (!ws.isOpen) return
301
+ try {
302
+ await sendNode({
303
+ tag: 'ib',
304
+ attrs: {},
305
+ content: [{ tag: 'unified_session', attrs: { id: getUnifiedSessionId() } }]
306
+ })
307
+ } catch (error) {
308
+ logger.debug({ error }, 'failed to send unified_session telemetry')
309
+ }
310
+ }
311
+
312
+ // ─── Connection lifecycle ────────────────────────────────────────────────────
313
+
314
+ const onUnexpectedError = (err, msg) => {
315
+ logger.error({ err }, `unexpected error in '${msg}'`)
316
+ }
317
+
318
+ const awaitNextMessage = async (sendMsg) => {
319
+ if (!ws.isOpen)
320
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
321
+ let onOpen, onClose
322
+ const result = promiseTimeout(connectTimeoutMs, (resolve, reject) => {
323
+ onOpen = resolve
324
+ onClose = mapWebSocketError(reject)
325
+ ws.on('frame', onOpen)
326
+ ws.on('close', onClose)
327
+ ws.on('error', onClose)
328
+ }).finally(() => {
329
+ ws.off('frame', onOpen)
330
+ ws.off('close', onClose)
331
+ ws.off('error', onClose)
332
+ })
333
+ if (sendMsg) sendRawMessage(sendMsg).catch(onClose)
334
+ return result
335
+ }
336
+
337
+ const validateConnection = async () => {
338
+ let helloMsg = proto.HandshakeMessage.fromObject({ clientHello: { ephemeral: ephemeralKeyPair.public } })
339
+ logger.info({ browser, helloMsg }, 'Connected to WhatsApp')
340
+ const init = proto.HandshakeMessage.encode(helloMsg).finish()
341
+ const result = await awaitNextMessage(init)
342
+ const handshake = proto.HandshakeMessage.decode(result)
343
+ logger.trace({ handshake }, 'Handshake received from WhatsApp')
344
+ const keyEnc = await noise.processHandshake(handshake, creds.noiseKey)
345
+ const node = !creds.me ? generateRegistrationNode(creds, config) : generateLoginNode(creds.me.id, config)
346
+ logger.info({ node }, !creds.me ? 'Attempting registration...' : 'Logging in...')
347
+ const payloadEnc = noise.encrypt(proto.ClientPayload.encode(node).finish())
348
+ await sendRawMessage(
349
+ proto.HandshakeMessage.encode({
350
+ clientFinish: { static: keyEnc, payload: payloadEnc }
351
+ }).finish()
352
+ )
353
+ await noise.finishInit()
354
+ startKeepAliveRequest()
355
+ }
356
+
357
+ const waitForSocketOpen = async () => {
358
+ if (ws.isOpen) return
359
+ if (ws.isClosed || ws.isClosing)
360
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
361
+ let onOpen, onClose
362
+ await new Promise((resolve, reject) => {
363
+ onOpen = () => resolve(undefined)
364
+ onClose = mapWebSocketError(reject)
365
+ ws.on('open', onOpen)
366
+ ws.on('close', onClose)
367
+ ws.on('error', onClose)
368
+ }).finally(() => {
369
+ ws.off('open', onOpen)
370
+ ws.off('close', onClose)
371
+ ws.off('error', onClose)
372
+ })
373
+ }
374
+
375
+ /**
376
+ * Keep-alive: ping WA every keepAliveIntervalMs.
377
+ * If the server stops responding (diff > interval + 5s) the connection
378
+ * is considered lost and we call end() — the consumer handles reconnection.
379
+ * No internal reconnect loop — clean separation of concerns.
380
+ */
381
+ const startKeepAliveRequest = () => {
382
+ keepAliveReq = setInterval(() => {
383
+ if (!lastDateRecv) lastDateRecv = new Date()
384
+ const diff = Date.now() - lastDateRecv.getTime()
385
+ if (diff > keepAliveIntervalMs + 5000) {
386
+ end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
387
+ } else if (ws.isOpen) {
388
+ query({
389
+ tag: 'iq',
390
+ attrs: { id: generateMessageTag(), to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:p' },
391
+ content: [{ tag: 'ping', attrs: {} }]
392
+ }).catch((err) => logger.error({ trace: err.stack }, 'error in sending keep alive'))
393
+ } else {
394
+ logger.warn('keep alive called when WS not open')
395
+ }
396
+ }, keepAliveIntervalMs)
397
+ }
398
+
399
+ const end = (error) => {
400
+ if (closed) { logger.trace({ trace: error?.stack }, 'Connection already closed'); return }
401
+ closed = true
402
+ logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed')
403
+ clearInterval(keepAliveReq)
404
+ clearTimeout(qrTimer)
405
+ ws.removeAllListeners('close')
406
+ ws.removeAllListeners('open')
407
+ ws.removeAllListeners('message')
408
+ if (!ws.isClosed && !ws.isClosing) { try { ws.close() } catch { } }
409
+ ev.emit('connection.update', { connection: 'close', lastDisconnect: { error, date: new Date() } })
410
+ ev.removeAllListeners('connection.update')
411
+ }
412
+
413
+ const sendPassiveIq = (tag) =>
414
+ query({
415
+ tag: 'iq',
416
+ attrs: { to: S_WHATSAPP_NET, xmlns: 'passive', type: 'set' },
417
+ content: [{ tag, attrs: {} }]
418
+ })
419
+
420
+ const logout = async (msg) => {
421
+ const jid = authState.creds.me?.id
422
+ if (jid) {
423
+ await sendNode({
424
+ tag: 'iq',
425
+ attrs: { to: S_WHATSAPP_NET, type: 'set', id: generateMessageTag(), xmlns: 'md' },
426
+ content: [{ tag: 'remove-companion-device', attrs: { jid, reason: 'user_initiated' } }]
427
+ })
428
+ }
429
+ end(new Boom(msg || 'Intentional Logout', { statusCode: DisconnectReason.loggedOut }))
430
+ }
431
+
432
+ // ─── Pairing ─────────────────────────────────────────────────────────────────
433
+
434
+ const requestPairingCode = async (phoneNumber, customPairingCode) => {
435
+ await waitForSocketOpen()
436
+ await new Promise(resolve => setTimeout(resolve, 500))
437
+ const pairingCode = customPairingCode ?? bytesToCrockford(randomBytes(5))
438
+ if (customPairingCode && customPairingCode?.length !== 8)
439
+ throw new Error('Custom pairing code must be exactly 8 chars')
440
+ authState.creds.pairingCode = pairingCode
441
+ authState.creds.me = { id: jidEncode(phoneNumber, 's.whatsapp.net'), name: '~' }
442
+ ev.emit('creds.update', authState.creds)
443
+ await sendNode({
444
+ tag: 'iq',
445
+ attrs: { to: S_WHATSAPP_NET, type: 'set', id: generateMessageTag(), xmlns: 'md' },
446
+ content: [{
447
+ tag: 'link_code_companion_reg',
448
+ attrs: { jid: authState.creds.me.id, stage: 'companion_hello', should_show_push_notification: 'true' },
449
+ content: [
450
+ { tag: 'link_code_pairing_wrapped_companion_ephemeral_pub', attrs: {}, content: await generatePairingKey() },
451
+ { tag: 'companion_server_auth_key_pub', attrs: {}, content: authState.creds.noiseKey.public },
452
+ { tag: 'companion_platform_id', attrs: {}, content: getPlatformId(browser[1]) },
453
+ { tag: 'companion_platform_display', attrs: {}, content: `${browser[1]} (${browser[0]})` },
454
+ { tag: 'link_code_pairing_nonce', attrs: {}, content: '0' }
455
+ ]
456
+ }]
457
+ })
458
+ return authState.creds.pairingCode
459
+ }
460
+
461
+ async function generatePairingKey() {
462
+ const salt = randomBytes(32)
463
+ const randomIv = randomBytes(16)
464
+ const key = await derivePairingCodeKey(authState.creds.pairingCode, salt)
465
+ const ciphered = aesEncryptCTR(authState.creds.pairingEphemeralKeyPair.public, key, randomIv)
466
+ return Buffer.concat([salt, randomIv, ciphered])
467
+ }
468
+
469
+ // ─── Incoming message processing ────────────────────────────────────────────
470
+
471
+ const onMessageReceived = (data) => {
472
+ noise.decodeFrame(data, (frame) => {
473
+ lastDateRecv = new Date()
474
+ let anyTriggered = ws.emit('frame', frame)
475
+ if (!(frame instanceof Uint8Array)) {
476
+ const msgId = frame.attrs.id
477
+ if (logger.level === 'trace') logger.trace({ xml: binaryNodeToString(frame), msg: 'recv xml' })
478
+ anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered
479
+ const l0 = frame.tag
480
+ const l1 = frame.attrs || {}
481
+ const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''
482
+ for (const key of Object.keys(l1)) {
483
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
484
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
485
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
486
+ }
487
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
488
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
489
+ if (!anyTriggered && logger.level === 'debug')
490
+ logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'Unhandled communication received')
491
+ }
492
+ })
493
+ }
494
+
495
+ const sendWAMBuffer = (wamBuffer) =>
496
+ query({
497
+ tag: 'iq',
498
+ attrs: { to: S_WHATSAPP_NET, id: generateMessageTag(), xmlns: 'w:stats' },
499
+ content: [{ tag: 'add', attrs: { t: Math.round(Date.now() / 1000) + '' }, content: wamBuffer }]
500
+ })
501
+
502
+ // ─── WebSocket event bindings ────────────────────────────────────────────────
503
+
504
+ ws.on('message', onMessageReceived)
505
+
506
+ ws.on('open', async () => {
507
+ try { await validateConnection() }
508
+ catch (err) { logger.error({ err }, 'error in validating connection'); end(err) }
509
+ })
510
+
511
+ // Let mapWebSocketError convert the raw error then call end()
512
+ ws.on('error', mapWebSocketError(end))
513
+
514
+ // Any close → end(), consumer decides whether to reconnect
515
+ ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })))
516
+
517
+ ws.on('CB:xmlstreamend', () => {
518
+ logger.info('Stream ended by server')
519
+ if (!closed) end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))
520
+ })
521
+
522
+ // ─── QR pairing ─────────────────────────────────────────────────────────────
523
+
524
+ ws.on('CB:iq,type:set,pair-device', async (stanza) => {
525
+ await sendNode({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'result', id: stanza.attrs.id } })
526
+ const pairDeviceNode = getBinaryNodeChild(stanza, 'pair-device')
527
+ const refNodes = getBinaryNodeChildren(pairDeviceNode, 'ref')
528
+ const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64')
529
+ const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
530
+ const advB64 = creds.advSecretKey
531
+ let qrMs = qrTimeout || 60000
532
+ const genPairQR = () => {
533
+ if (!ws.isOpen) return
534
+ const refNode = refNodes.shift()
535
+ if (!refNode) { end(new Boom('QR refs attempts ended', { statusCode: DisconnectReason.timedOut })); return }
536
+ const ref = refNode.content.toString('utf-8')
537
+ const qr = [ref, noiseKeyB64, identityKeyB64, advB64].join(',')
538
+ ev.emit('connection.update', { qr })
539
+ qrTimer = setTimeout(genPairQR, qrMs)
540
+ qrMs = qrTimeout || 20000
541
+ }
542
+ genPairQR()
543
+ })
544
+
545
+ ws.on('CB:iq,,pair-success', async (stanza) => {
546
+ logger.debug('Pair success received')
547
+ try {
548
+ updateServerTimeOffset(stanza)
549
+ const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
550
+ logger.info({ me: updatedCreds.me, platform: updatedCreds.platform }, 'Pairing configured successfully')
551
+ ev.emit('creds.update', updatedCreds)
552
+ ev.emit('connection.update', { isNewLogin: true, qr: undefined })
553
+ await sendNode(reply)
554
+ void sendUnifiedSession()
555
+ } catch (error) {
556
+ logger.info({ trace: error.stack }, 'Error in pairing')
557
+ end(error)
558
+ }
559
+ })
560
+
561
+ // ─── Login complete ──────────────────────────────────────────────────────────
562
+
563
+ ws.on('CB:success', async (node) => {
564
+ try {
565
+ updateServerTimeOffset(node)
566
+ await uploadPreKeysToServerIfRequired()
567
+ await sendPassiveIq('active')
568
+ try {
569
+ await digestKeyBundle()
570
+ } catch (e) {
571
+ logger.warn({ e }, 'failed to run digest after login')
572
+ }
573
+ } catch (err) {
574
+ logger.warn({ err }, 'Failed to send initial passive IQ')
575
+ }
576
+
577
+ logger.info('✅ Opened connection to WhatsApp')
578
+ clearTimeout(qrTimer)
579
+ ev.emit('creds.update', { me: { ...authState.creds.me, lid: node.attrs.lid } })
580
+ ev.emit('connection.update', { connection: 'open' })
581
+ void sendUnifiedSession()
582
+
583
+ if (node.attrs.lid && authState.creds.me?.id) {
584
+ const myLID = node.attrs.lid
585
+ process.nextTick(async () => {
586
+ try {
587
+ const myPN = authState.creds.me.id
588
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: myLID, pn: myPN }])
589
+ const { user, device } = jidDecode(myPN)
590
+ const currentBatch = await migrateIndexKey(authState.keys, 'device-list')
591
+ currentBatch[user] = [device?.toString() || '0']
592
+ const deviceKeys = Object.keys(currentBatch)
593
+ if (deviceKeys.length > BATCH_SIZE) {
594
+ deviceKeys.sort()
595
+ deviceKeys.slice(0, deviceKeys.length - BATCH_SIZE).forEach(k => delete currentBatch[k])
596
+ }
597
+ await authState.keys.set({ 'device-list': { 'index': currentBatch } })
598
+ await signalRepository.migrateSession(myPN, myLID)
599
+ logger.info({ myPN, myLID }, 'Own LID session created successfully')
600
+ if (signalRepository.migrateAllPNSessionsToLID) {
601
+ try {
602
+ const migrated = await signalRepository.migrateAllPNSessionsToLID()
603
+ if (migrated > 0) logger.info({ migrated }, 'Batch-migrated PN sessions to LID on connect')
604
+ } catch (migErr) {
605
+ logger.warn({ error: migErr }, 'Failed to batch-migrate PN sessions to LID')
606
+ }
607
+ }
608
+ } catch (error) {
609
+ logger.error({ error, lid: myLID }, 'Failed to create own LID session')
610
+ }
611
+ })
612
+ }
613
+ })
614
+
615
+ // ─── Stream / connection error handlers ─────────────────────────────────────
616
+
617
+ ws.on('CB:stream:error', (node) => {
618
+ logger.error({ node }, 'Stream errored out')
619
+ const { reason, statusCode } = getErrorCodeFromStreamError(node)
620
+ end(new Boom(`Stream Errored (${reason})`, { statusCode, data: node }))
621
+ })
622
+
623
+ ws.on('CB:failure', (node) => {
624
+ const reason = +(node.attrs.reason || 500)
625
+ end(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }))
626
+ })
627
+
628
+ ws.on('CB:ib,,downgrade_webclient', () =>
629
+ end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }))
630
+ )
631
+
632
+ ws.on('CB:ib,,offline_preview', (node) => {
633
+ logger.info('Offline preview received', JSON.stringify(node))
634
+ sendNode({ tag: 'ib', attrs: {}, content: [{ tag: 'offline_batch', attrs: { count: '100' } }] })
635
+ })
636
+
637
+ ws.on('CB:ib,,edge_routing', (node) => {
638
+ const edgeRoutingNode = getBinaryNodeChild(node, 'edge_routing')
639
+ const routingInfo = getBinaryNodeChild(edgeRoutingNode, 'routing_info')
640
+ if (routingInfo?.content) {
641
+ authState.creds.routingInfo = Buffer.from(routingInfo?.content)
642
+ ev.emit('creds.update', authState.creds)
643
+ }
644
+ })
645
+
646
+ // ─── Buffering & offline notifications ──────────────────────────────────────
647
+
648
+ let didStartBuffer = false
649
+ process.nextTick(() => {
650
+ if (creds.me?.id) { ev.buffer(); didStartBuffer = true }
651
+ ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false, qr: undefined })
652
+ })
653
+
654
+ ws.on('CB:ib,,offline', (node) => {
655
+ const child = getBinaryNodeChild(node, 'offline')
656
+ const offlineNotifs = +(child?.attrs.count || 0)
657
+ logger.info(`Handled ${offlineNotifs} offline messages/notifications`)
658
+ if (didStartBuffer) { ev.flush(); logger.trace('Flushed events for initial buffer') }
659
+ ev.emit('connection.update', { receivedPendingNotifications: true })
660
+ })
661
+
662
+ // ─── Creds sync ──────────────────────────────────────────────────────────────
663
+
664
+ ev.on('creds.update', (update) => {
665
+ const name = update.me?.name
666
+ if (creds.me?.name !== name) {
667
+ logger.debug({ name }, 'Updated pushName')
668
+ sendNode({ tag: 'presence', attrs: { name } }).catch((err) =>
669
+ logger.warn({ trace: err.stack }, 'Error in sending presence update on name change')
670
+ )
671
+ }
672
+ Object.assign(creds, update)
673
+ })
674
+
675
+ // ─── Public API ──────────────────────────────────────────────────────────────
676
+
677
+ return {
678
+ type: 'md',
679
+ ws,
680
+ ev,
681
+ authState: { creds, keys },
682
+ signalRepository,
683
+ get user() { return authState.creds.me },
684
+ generateMessageTag,
685
+ query,
686
+ waitForMessage,
687
+ waitForSocketOpen,
688
+ sendRawMessage,
689
+ sendNode,
690
+ logout,
691
+ end,
692
+ onUnexpectedError,
693
+ uploadPreKeys,
694
+ uploadPreKeysToServerIfRequired,
695
+ digestKeyBundle,
696
+ rotateSignedPreKey,
697
+ updateServerTimeOffset,
698
+ sendUnifiedSession,
699
+ requestPairingCode,
700
+ wamBuffer: publicWAMBuffer,
701
+ waitForConnectionUpdate: bindWaitForConnectionUpdate(ev),
702
+ sendWAMBuffer,
703
+ executeUSyncQuery,
704
+ listener: (eventName) => {
705
+ if (typeof ev.listenerCount === 'function') return ev.listenerCount(eventName)
706
+ if (typeof ev.listener === 'function') return ev.listener(eventName)?.length || 0
707
+ return 0
708
+ }
709
+ }
1202
710
  }