@nexustechpro/baileys 2.0.2 → 2.0.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.
Files changed (108) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +924 -1299
  3. package/WAProto/index.js +22 -18
  4. package/lib/Defaults/baileys-version.json +6 -2
  5. package/lib/Defaults/index.js +173 -172
  6. package/lib/Signal/libsignal.js +395 -292
  7. package/lib/Signal/lid-mapping.js +264 -171
  8. package/lib/Socket/Client/index.js +2 -2
  9. package/lib/Socket/Client/types.js +10 -10
  10. package/lib/Socket/Client/websocket.js +45 -310
  11. package/lib/Socket/business.js +375 -375
  12. package/lib/Socket/chats.js +916 -963
  13. package/lib/Socket/communities.js +430 -430
  14. package/lib/Socket/groups.js +342 -342
  15. package/lib/Socket/index.js +21 -22
  16. package/lib/Socket/messages-recv.js +963 -743
  17. package/lib/Socket/messages-send.js +273 -321
  18. package/lib/Socket/mex.js +50 -50
  19. package/lib/Socket/newsletter.js +148 -148
  20. package/lib/Socket/nexus-handler.js +296 -247
  21. package/lib/Socket/registration.js +50 -33
  22. package/lib/Socket/socket.js +872 -1201
  23. package/lib/Store/index.js +5 -5
  24. package/lib/Store/make-cache-manager-store.js +81 -81
  25. package/lib/Store/make-in-memory-store.js +416 -416
  26. package/lib/Store/make-ordered-dictionary.js +81 -81
  27. package/lib/Store/object-repository.js +30 -30
  28. package/lib/Types/Auth.js +1 -1
  29. package/lib/Types/Bussines.js +1 -1
  30. package/lib/Types/Call.js +1 -1
  31. package/lib/Types/Chat.js +7 -7
  32. package/lib/Types/Contact.js +1 -1
  33. package/lib/Types/Events.js +1 -1
  34. package/lib/Types/GroupMetadata.js +1 -1
  35. package/lib/Types/Label.js +24 -24
  36. package/lib/Types/LabelAssociation.js +6 -6
  37. package/lib/Types/Message.js +10 -10
  38. package/lib/Types/Newsletter.js +37 -29
  39. package/lib/Types/Product.js +1 -1
  40. package/lib/Types/Signal.js +1 -1
  41. package/lib/Types/Socket.js +2 -2
  42. package/lib/Types/State.js +55 -12
  43. package/lib/Types/USync.js +1 -1
  44. package/lib/Types/index.js +25 -25
  45. package/lib/Utils/auth-utils.js +264 -256
  46. package/lib/Utils/baileys-event-stream.js +55 -55
  47. package/lib/Utils/browser-utils.js +27 -27
  48. package/lib/Utils/business.js +228 -230
  49. package/lib/Utils/chat-utils.js +726 -764
  50. package/lib/Utils/companion-reg-client-utils.js +34 -0
  51. package/lib/Utils/crypto.js +109 -135
  52. package/lib/Utils/decode-wa-message.js +342 -314
  53. package/lib/Utils/event-buffer.js +547 -547
  54. package/lib/Utils/generics.js +295 -297
  55. package/lib/Utils/history.js +91 -83
  56. package/lib/Utils/index.js +25 -20
  57. package/lib/Utils/key-store.js +17 -0
  58. package/lib/Utils/link-preview.js +107 -98
  59. package/lib/Utils/logger.js +2 -2
  60. package/lib/Utils/lt-hash.js +47 -47
  61. package/lib/Utils/make-mutex.js +39 -39
  62. package/lib/Utils/message-retry-manager.js +148 -148
  63. package/lib/Utils/messages-media.js +579 -535
  64. package/lib/Utils/messages.js +821 -706
  65. package/lib/Utils/noise-handler.js +255 -255
  66. package/lib/Utils/pre-key-manager.js +105 -105
  67. package/lib/Utils/process-message.js +430 -412
  68. package/lib/Utils/reporting-utils.js +155 -0
  69. package/lib/Utils/signal.js +191 -159
  70. package/lib/Utils/sync-action-utils.js +33 -0
  71. package/lib/Utils/tc-token-utils.js +162 -0
  72. package/lib/Utils/use-multi-file-auth-state.js +120 -120
  73. package/lib/Utils/validate-connection.js +194 -194
  74. package/lib/WABinary/constants.js +1306 -1300
  75. package/lib/WABinary/decode.js +237 -237
  76. package/lib/WABinary/encode.js +232 -232
  77. package/lib/WABinary/generic-utils.js +252 -211
  78. package/lib/WABinary/index.js +6 -5
  79. package/lib/WABinary/jid-utils.js +279 -95
  80. package/lib/WABinary/types.js +1 -1
  81. package/lib/WAM/BinaryInfo.js +9 -9
  82. package/lib/WAM/constants.js +22852 -22852
  83. package/lib/WAM/encode.js +149 -149
  84. package/lib/WAM/index.js +3 -3
  85. package/lib/WAUSync/Protocols/USyncContactProtocol.js +28 -28
  86. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +53 -53
  87. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +26 -26
  88. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +37 -37
  89. package/lib/WAUSync/Protocols/UsyncBotProfileProtocol.js +50 -50
  90. package/lib/WAUSync/Protocols/UsyncLIDProtocol.js +28 -28
  91. package/lib/WAUSync/Protocols/index.js +4 -4
  92. package/lib/WAUSync/USyncQuery.js +93 -93
  93. package/lib/WAUSync/USyncUser.js +22 -22
  94. package/lib/WAUSync/index.js +3 -3
  95. package/lib/index.js +65 -66
  96. package/package.json +172 -143
  97. package/lib/Signal/Group/ciphertext-message.js +0 -12
  98. package/lib/Signal/Group/group-session-builder.js +0 -30
  99. package/lib/Signal/Group/group_cipher.js +0 -100
  100. package/lib/Signal/Group/index.js +0 -12
  101. package/lib/Signal/Group/keyhelper.js +0 -18
  102. package/lib/Signal/Group/sender-chain-key.js +0 -26
  103. package/lib/Signal/Group/sender-key-distribution-message.js +0 -63
  104. package/lib/Signal/Group/sender-key-message.js +0 -66
  105. package/lib/Signal/Group/sender-key-name.js +0 -48
  106. package/lib/Signal/Group/sender-key-record.js +0 -41
  107. package/lib/Signal/Group/sender-key-state.js +0 -84
  108. package/lib/Signal/Group/sender-message-key.js +0 -26
@@ -1,1202 +1,873 @@
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 {
7
+ DEF_CALLBACK_PREFIX,
8
+ DEF_TAG_PREFIX,
9
+ INITIAL_PREKEY_COUNT,
10
+ MIN_PREKEY_COUNT,
11
+ MIN_UPLOAD_INTERVAL,
12
+ NOISE_WA_HEADER,
13
+ UPLOAD_TIMEOUT,
14
+ BATCH_SIZE,
15
+ TimeMs
16
+ } from '../Defaults/index.js'
17
+ import { QueryIds, ReachoutTimelockEnforcementType } from '../Types/index.js'
18
+ import { DisconnectReason, XWAPaths } from '../Types/index.js'
19
+ import {
20
+ addTransactionCapability,
21
+ aesEncryptCTR,
22
+ bindWaitForConnectionUpdate,
23
+ buildPairingQRData,
24
+ bytesToCrockford,
25
+ configureSuccessfulPairing,
26
+ Curve,
27
+ derivePairingCodeKey,
28
+ generateLoginNode,
29
+ generateMdTagPrefix,
30
+ generateRegistrationNode,
31
+ getCodeFromWSError,
32
+ getCompanionPlatformId,
33
+ getErrorCodeFromStreamError,
34
+ getNextPreKeysNode,
35
+ makeEventBuffer,
36
+ makeNoiseHandler,
37
+ promiseTimeout,
38
+ signedKeyPair,
39
+ xmppSignedPreKey
40
+ } from '../Utils/index.js'
41
+ import { getPlatformId, migrateIndexKey } from '../Utils/index.js'
42
+ import {
43
+ assertNodeErrorFree,
44
+ binaryNodeToString,
45
+ encodeBinaryNode,
46
+ getAllBinaryNodeChildren,
47
+ getBinaryNodeChild,
48
+ getBinaryNodeChildren,
49
+ isLidUser,
50
+ jidDecode,
51
+ jidEncode,
52
+ S_WHATSAPP_NET
53
+ } from '../WABinary/index.js'
54
+ import { BinaryInfo } from '../WAM/BinaryInfo.js'
55
+ import { USyncQuery, USyncUser } from '../WAUSync/index.js'
56
+ import { WebSocketClient } from './Client/index.js'
57
+ import { executeWMexQuery } from './mex.js'
58
+
59
+ // ─── Module-scope helpers ──────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Map a raw WebSocket error into a Boom so callers
63
+ * can inspect the statusCode / DisconnectReason.
64
+ */
65
+ const mapWebSocketError = (handler) => (error) =>
66
+ handler(
67
+ new Boom(`WebSocket Error (${error?.message})`, {
68
+ statusCode: getCodeFromWSError(error),
69
+ data: error
70
+ })
71
+ )
72
+
73
+ // ─── Factory ──────────────────────────────────────────────────────────────────
74
+
75
+ export const makeSocket = (config) => {
76
+ const {
77
+ waWebSocketUrl,
78
+ connectTimeoutMs,
79
+ logger,
80
+ keepAliveIntervalMs,
81
+ browser,
82
+ auth: authState,
83
+ printQRInTerminal,
84
+ defaultQueryTimeoutMs,
85
+ transactionOpts,
86
+ qrTimeout,
87
+ makeSignalRepository
88
+ } = config
89
+
90
+ if (printQRInTerminal) {
91
+ logger?.warn(
92
+ {},
93
+ '⚠️ printQRInTerminal is deprecated. Listen to connection.update and handle QR yourself.'
94
+ )
95
+ }
96
+
97
+ const url = typeof waWebSocketUrl === 'string' ? new URL(waWebSocketUrl) : waWebSocketUrl
98
+
99
+ if (config.mobile || url.protocol === 'tcp:')
100
+ throw new Boom('Mobile API not supported', { statusCode: DisconnectReason.loggedOut })
101
+
102
+ if (url.protocol === 'wss:' && authState?.creds?.routingInfo)
103
+ url.searchParams.append('ED', authState.creds.routingInfo.toString('base64url'))
104
+
105
+ const ephemeralKeyPair = Curve.generateKeyPair()
106
+ const noise = makeNoiseHandler({
107
+ keyPair: ephemeralKeyPair,
108
+ NOISE_HEADER: NOISE_WA_HEADER,
109
+ logger,
110
+ routingInfo: authState?.creds?.routingInfo
111
+ })
112
+
113
+ const ws = new WebSocketClient(url, config)
114
+ logger.info({ url: url.toString() }, 'Initiating WebSocket connection')
115
+ ws.connect()
116
+
117
+ const ev = makeEventBuffer(logger)
118
+ const { creds } = authState
119
+ const keys = addTransactionCapability(authState.keys, logger, transactionOpts)
120
+ const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync)
121
+ const publicWAMBuffer = new BinaryInfo()
122
+ const uqTagId = generateMdTagPrefix()
123
+ const sendPromise = promisify(ws.send)
124
+
125
+ // State
126
+ let epoch = 1
127
+ let lastDateRecv
128
+ let lastUploadTime = 0
129
+ let uploadPreKeysPromise = null
130
+ let closed = false
131
+ let keepAliveReq
132
+ let qrTimer
133
+ let serverTimeOffsetMs = 0
134
+ let didStartBuffer = false
135
+
136
+ /** Socket end handlers — registered via registerSocketEndHandler() */
137
+ const socketEndHandlers = []
138
+
139
+ const generateMessageTag = () => `${uqTagId}${epoch++}`
140
+
141
+ // ─── Transport ──────────────────────────────────────────────────────────────
142
+
143
+ const sendRawMessage = async (data) => {
144
+ if (!ws.isOpen)
145
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
146
+ const bytes = noise.encodeFrame(data)
147
+ await promiseTimeout(connectTimeoutMs, async (resolve, reject) => {
148
+ try { await sendPromise.call(ws, bytes); resolve() }
149
+ catch (error) { reject(error) }
150
+ })
151
+ }
152
+
153
+ const sendNode = (frame) => {
154
+ if (logger.level === 'trace') logger.trace({ xml: binaryNodeToString(frame), msg: 'xml send' })
155
+ return sendRawMessage(encodeBinaryNode(frame))
156
+ }
157
+
158
+ // ─── Query / messaging ──────────────────────────────────────────────────────
159
+
160
+ const waitForMessage = async (msgId, timeoutMs = defaultQueryTimeoutMs) => {
161
+ let onRecv, onErr
162
+ try {
163
+ return await promiseTimeout(timeoutMs, (resolve, reject) => {
164
+ onRecv = (data) => resolve(data)
165
+ onErr = (err) =>
166
+ reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
167
+ ws.on(`TAG:${msgId}`, onRecv)
168
+ ws.on('close', onErr)
169
+ ws.on('error', onErr)
170
+ return () => reject(new Boom('Query Cancelled'))
171
+ })
172
+ } catch (error) {
173
+ if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
174
+ logger?.warn?.({ msgId }, 'timed out waiting for message')
175
+ return undefined
176
+ }
177
+ throw error
178
+ } finally {
179
+ if (onRecv) ws.off(`TAG:${msgId}`, onRecv)
180
+ if (onErr) { ws.off('close', onErr); ws.off('error', onErr) }
181
+ }
182
+ }
183
+
184
+ const query = async (node, timeoutMs) => {
185
+ if (!node.attrs.id) node.attrs.id = generateMessageTag()
186
+ const msgId = node.attrs.id
187
+ const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
188
+ const result = waitForMessage(msgId, timeoutMs).catch(reject)
189
+ sendNode(node).then(async () => resolve(await result)).catch(reject)
190
+ })
191
+ if (result && 'tag' in result) assertNodeErrorFree(result)
192
+ return result
193
+ }
194
+
195
+ // ─── USync ──────────────────────────────────────────────────────────────────
196
+
197
+ const executeUSyncQuery = async (usyncQuery) => {
198
+ if (usyncQuery.protocols.length === 0)
199
+ throw new Boom('USyncQuery must have at least one protocol')
200
+ const userNodes = usyncQuery.users.map((user) => ({
201
+ tag: 'user',
202
+ attrs: { jid: !user.phone ? user.id : undefined },
203
+ content: usyncQuery.protocols.map((a) => a.getUserElement(user)).filter((a) => a !== null)
204
+ }))
205
+ const iq = {
206
+ tag: 'iq',
207
+ attrs: { to: S_WHATSAPP_NET, type: 'get', xmlns: 'usync' },
208
+ content: [{
209
+ tag: 'usync',
210
+ attrs: {
211
+ context: usyncQuery.context,
212
+ mode: usyncQuery.mode,
213
+ sid: generateMessageTag(),
214
+ last: 'true',
215
+ index: '0'
216
+ },
217
+ content: [
218
+ { tag: 'query', attrs: {}, content: usyncQuery.protocols.map((a) => a.getQueryElement()) },
219
+ { tag: 'list', attrs: {}, content: userNodes }
220
+ ]
221
+ }]
222
+ }
223
+ return usyncQuery.parseUSyncQueryResult(await query(iq))
224
+ }
225
+
226
+ async function pnFromLIDUSync(jids) {
227
+ const usyncQuery = new USyncQuery().withLIDProtocol().withContext('background')
228
+ for (const jid of jids) {
229
+ if (isLidUser(jid)) { logger?.warn('LID user found in LID fetch call'); continue }
230
+ usyncQuery.withUser(new USyncUser().withId(jid))
231
+ }
232
+ if (usyncQuery.users.length === 0) return []
233
+ const results = await executeUSyncQuery(usyncQuery)
234
+ return results ? results.list.filter((a) => !!a.lid).map(({ lid, id }) => ({ pn: id, lid })) : []
235
+ }
236
+
237
+ const onWhatsApp = async (...phoneNumbers) => {
238
+ let usyncQuery = new USyncQuery()
239
+ let contactEnabled = false
240
+ for (const jid of phoneNumbers) {
241
+ if (isLidUser(jid)) { logger?.warn('LIDs are not supported with onWhatsApp'); continue }
242
+ if (!contactEnabled) { contactEnabled = true; usyncQuery = usyncQuery.withContactProtocol() }
243
+ const phone = `+${jid.replace('+', '').split('@')[0]?.split(':')[0]}`
244
+ usyncQuery.withUser(new USyncUser().withPhone(phone))
245
+ }
246
+ if (usyncQuery.users.length === 0) return []
247
+ const results = await executeUSyncQuery(usyncQuery)
248
+ return results
249
+ ? results.list.filter((a) => !!a.contact).map(({ contact, id }) => ({ jid: id, exists: contact }))
250
+ : []
251
+ }
252
+
253
+ // ─── Pre-keys ───────────────────────────────────────────────────────────────
254
+
255
+ const getAvailablePreKeysOnServer = async () => {
256
+ const result = await query({
257
+ tag: 'iq',
258
+ attrs: { id: generateMessageTag(), xmlns: 'encrypt', type: 'get', to: S_WHATSAPP_NET },
259
+ content: [{ tag: 'count', attrs: {} }]
260
+ })
261
+ return +getBinaryNodeChild(result, 'count').attrs.value
262
+ }
263
+
264
+ /**
265
+ * Verify that our current pre-key actually exists in local key storage.
266
+ * Catches the case where the server still has keys but our local store is missing them.
267
+ */
268
+ const verifyCurrentPreKeyExists = async () => {
269
+ const currentPreKeyId = creds.nextPreKeyId - 1
270
+ if (currentPreKeyId <= 0) return { exists: false, currentPreKeyId: 0 }
271
+ const preKeys = await keys.get('pre-key', [currentPreKeyId.toString()])
272
+ return { exists: !!preKeys[currentPreKeyId.toString()], currentPreKeyId }
273
+ }
274
+
275
+ const uploadPreKeys = async (count = MIN_PREKEY_COUNT, retryCount = 0) => {
276
+ // Rate-limit guard — only on the first attempt, not retries
277
+ if (retryCount === 0 && Date.now() - lastUploadTime < MIN_UPLOAD_INTERVAL) {
278
+ logger.debug(`Skipping upload — only ${Date.now() - lastUploadTime}ms since last upload`)
279
+ return
280
+ }
281
+ // Dedup: if an upload is already in-flight, wait for it
282
+ if (uploadPreKeysPromise) {
283
+ logger.debug('Pre-key upload in progress, waiting...')
284
+ await uploadPreKeysPromise
285
+ return
286
+ }
287
+ const uploadLogic = async () => {
288
+ logger.info({ count, retryCount }, 'Uploading pre-keys')
289
+ // Generate keys inside a transaction to prevent ID collisions on retry
290
+ const node = await keys.transaction(async () => {
291
+ const { update, node } = await getNextPreKeysNode({ creds, keys }, count)
292
+ ev.emit('creds.update', update)
293
+ return node
294
+ }, creds?.me?.id || 'upload-pre-keys')
295
+ try {
296
+ await query(node)
297
+ logger.info({ count }, '✅ Pre-keys uploaded successfully')
298
+ lastUploadTime = Date.now()
299
+ } catch (uploadError) {
300
+ logger.error({ uploadError: uploadError.toString(), count }, 'Failed to upload pre-keys')
301
+ if (retryCount < 3) {
302
+ const backoffDelay = Math.min(1000 * Math.pow(2, retryCount), 10000)
303
+ logger.info(`Retrying pre-key upload in ${backoffDelay}ms`)
304
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay))
305
+ return uploadPreKeys(count, retryCount + 1)
306
+ }
307
+ throw uploadError
308
+ }
309
+ }
310
+ uploadPreKeysPromise = Promise.race([
311
+ uploadLogic(),
312
+ new Promise((_, reject) =>
313
+ setTimeout(() => reject(new Boom('Pre-key upload timeout', { statusCode: 408 })), UPLOAD_TIMEOUT)
314
+ )
315
+ ])
316
+ try { await uploadPreKeysPromise } finally { uploadPreKeysPromise = null }
317
+ }
318
+
319
+ const uploadPreKeysToServerIfRequired = async () => {
320
+ try {
321
+ const preKeyCount = await getAvailablePreKeysOnServer()
322
+ logger.info(`${preKeyCount} pre-keys found on server`)
323
+ const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
324
+ logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
325
+ const lowServerCount = preKeyCount <= MIN_PREKEY_COUNT
326
+ const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
327
+ if (lowServerCount || missingCurrentPreKey) {
328
+ const reasons = []
329
+ if (lowServerCount) reasons.push(`server count low (${preKeyCount})`)
330
+ if (missingCurrentPreKey) reasons.push(`current prekey ${currentPreKeyId} missing from storage`)
331
+ logger.info(`Uploading PreKeys due to: ${reasons.join(', ')}`)
332
+ const uploadCount = preKeyCount === 0 ? INITIAL_PREKEY_COUNT : MIN_PREKEY_COUNT
333
+ await uploadPreKeys(uploadCount)
334
+ } else {
335
+ logger.info(`✅ PreKey validation passed — Server: ${preKeyCount}, prekey ${currentPreKeyId} exists`)
336
+ }
337
+ } catch (error) {
338
+ logger.error({ error }, 'Failed to check/upload pre-keys during init')
339
+ // Non-fatal allow connection to continue
340
+ }
341
+ }
342
+
343
+ // ─── Key-bundle digest & signed pre-key rotation ────────────────────────────
344
+
345
+ /**
346
+ * Validate our current key-bundle against the server.
347
+ * If the server returns no digest node our keys are out of sync —
348
+ * force a pre-key upload and surface the error so the caller can decide.
349
+ */
350
+ const digestKeyBundle = async () => {
351
+ const res = await query({
352
+ tag: 'iq',
353
+ attrs: { to: S_WHATSAPP_NET, type: 'get', xmlns: 'encrypt' },
354
+ content: [{ tag: 'digest', attrs: {} }]
355
+ })
356
+ const digestNode = getBinaryNodeChild(res, 'digest')
357
+ if (!digestNode) {
358
+ await uploadPreKeys()
359
+ throw new Error('encrypt/get digest returned no digest node')
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Rotate our signed pre-key on the server.
365
+ * Should be called periodically (e.g. every 7 days) to keep sessions healthy.
366
+ */
367
+ const rotateSignedPreKey = async () => {
368
+ const newId = (creds.signedPreKey.keyId || 0) + 1
369
+ const skey = await signedKeyPair(creds.signedIdentityKey, newId)
370
+ await query({
371
+ tag: 'iq',
372
+ attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'encrypt' },
373
+ content: [{ tag: 'rotate', attrs: {}, content: [xmppSignedPreKey(skey)] }]
374
+ })
375
+ ev.emit('creds.update', { signedPreKey: skey })
376
+ }
377
+
378
+ // ─── Server time offset ─────────────────────────────────────────────────────
379
+
380
+ const updateServerTimeOffset = ({ attrs }) => {
381
+ const tValue = attrs?.t
382
+ if (!tValue) return
383
+ const parsed = Number(tValue)
384
+ if (Number.isNaN(parsed) || parsed <= 0) return
385
+ serverTimeOffsetMs = parsed * 1000 - Date.now()
386
+ logger.debug({ offset: serverTimeOffsetMs }, 'Calculated server time offset')
387
+ }
388
+
389
+ // ─── Unified session telemetry ───────────────────────────────────────────────
390
+
391
+ const getUnifiedSessionId = () => {
392
+ const offsetMs = 3 * TimeMs.Day
393
+ const now = Date.now() + serverTimeOffsetMs
394
+ return ((now + offsetMs) % TimeMs.Week).toString()
395
+ }
396
+
397
+ const sendUnifiedSession = async () => {
398
+ if (!ws.isOpen) return
399
+ try {
400
+ await sendNode({
401
+ tag: 'ib',
402
+ attrs: {},
403
+ content: [{ tag: 'unified_session', attrs: { id: getUnifiedSessionId() } }]
404
+ })
405
+ } catch (error) {
406
+ logger.debug({ error }, 'Failed to send unified_session telemetry')
407
+ }
408
+ }
409
+
410
+ // ─── WAM buffer ─────────────────────────────────────────────────────────────
411
+
412
+ const sendWAMBuffer = (wamBuffer) =>
413
+ query({
414
+ tag: 'iq',
415
+ attrs: { to: S_WHATSAPP_NET, id: generateMessageTag(), xmlns: 'w:stats' },
416
+ content: [{ tag: 'add', attrs: { t: Math.round(Date.now() / 1000) + '' }, content: wamBuffer }]
417
+ })
418
+
419
+ // ─── WMex queries ────────────────────────────────────────────────────────────
420
+
421
+ /**
422
+ * Fetches account restriction / reachout timelock status.
423
+ */
424
+ const fetchAccountReachoutTimelock = async () => {
425
+ const queryResult = await executeWMexQuery(
426
+ {},
427
+ QueryIds.REACHOUT_TIMELOCK,
428
+ XWAPaths.xwa2_fetch_account_reachout_timelock,
429
+ query,
430
+ generateMessageTag
431
+ )
432
+ const result = {
433
+ isActive: !!queryResult?.is_active,
434
+ timeEnforcementEnds:
435
+ queryResult?.time_enforcement_ends && queryResult.time_enforcement_ends !== '0'
436
+ ? new Date(parseInt(queryResult.time_enforcement_ends, 10) * 1000)
437
+ : undefined,
438
+ enforcementType: queryResult?.enforcement_type ?? ReachoutTimelockEnforcementType.DEFAULT
439
+ }
440
+ ev.emit('connection.update', { reachoutTimeLock: result })
441
+ return result
442
+ }
443
+
444
+ /**
445
+ * Fetches new-chat message cap quota and usage.
446
+ */
447
+ const fetchNewChatMessageCap = async () =>
448
+ executeWMexQuery(
449
+ { input: { type: 'INDIVIDUAL_NEW_CHAT_MSG' } },
450
+ QueryIds.MESSAGE_CAPPING_INFO,
451
+ XWAPaths.xwa2_message_capping_info,
452
+ query,
453
+ generateMessageTag
454
+ )
455
+
456
+ // ─── Connection lifecycle ────────────────────────────────────────────────────
457
+
458
+ const onUnexpectedError = (err, msg) => {
459
+ const isClosed = err?.message?.includes('Connection Closed') || err?.output?.statusCode === 428
460
+ if (isClosed) {
461
+ logger.debug({ msg: err?.message }, `Connection closed during '${msg}'`)
462
+ } else {
463
+ logger.error({ err }, `unexpected error in '${msg}'`)
464
+ }
465
+ }
466
+
467
+ const awaitNextMessage = async (sendMsg) => {
468
+ if (!ws.isOpen)
469
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
470
+ let onOpen, onClose
471
+ const result = promiseTimeout(connectTimeoutMs, (resolve, reject) => {
472
+ onOpen = resolve
473
+ onClose = mapWebSocketError(reject)
474
+ ws.on('frame', onOpen)
475
+ ws.on('close', onClose)
476
+ ws.on('error', onClose)
477
+ }).finally(() => {
478
+ ws.off('frame', onOpen)
479
+ ws.off('close', onClose)
480
+ ws.off('error', onClose)
481
+ })
482
+ if (sendMsg) sendRawMessage(sendMsg).catch(onClose)
483
+ return result
484
+ }
485
+
486
+ const validateConnection = async () => {
487
+ const helloMsg = proto.HandshakeMessage.fromObject({ clientHello: { ephemeral: ephemeralKeyPair.public } })
488
+ logger.info({ browser, helloMsg }, 'Connected to WhatsApp')
489
+ const init = proto.HandshakeMessage.encode(helloMsg).finish()
490
+ const result = await awaitNextMessage(init)
491
+ const handshake = proto.HandshakeMessage.decode(result)
492
+ logger.trace({ handshake }, 'Handshake received from WhatsApp')
493
+ const keyEnc = await noise.processHandshake(handshake, creds.noiseKey)
494
+ const node = !creds.me ? generateRegistrationNode(creds, config) : generateLoginNode(creds.me.id, config)
495
+ logger.info({ node }, !creds.me ? 'Attempting registration...' : 'Logging in...')
496
+ const payloadEnc = noise.encrypt(proto.ClientPayload.encode(node).finish())
497
+ await sendRawMessage(
498
+ proto.HandshakeMessage.encode({ clientFinish: { static: keyEnc, payload: payloadEnc } }).finish()
499
+ )
500
+ await noise.finishInit()
501
+ startKeepAliveRequest()
502
+ }
503
+
504
+ const waitForSocketOpen = async () => {
505
+ if (ws.isOpen) return
506
+ if (ws.isClosed || ws.isClosing)
507
+ throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
508
+ let onOpen, onClose
509
+ await new Promise((resolve, reject) => {
510
+ onOpen = () => resolve(undefined)
511
+ onClose = mapWebSocketError(reject)
512
+ ws.on('open', onOpen)
513
+ ws.on('close', onClose)
514
+ ws.on('error', onClose)
515
+ }).finally(() => {
516
+ ws.off('open', onOpen)
517
+ ws.off('close', onClose)
518
+ ws.off('error', onClose)
519
+ })
520
+ }
521
+
522
+ /**
523
+ * Keep-alive: ping WA every keepAliveIntervalMs.
524
+ * If the server stops responding (diff > interval + 5s) the connection
525
+ * is considered lost and we call end() — the consumer handles reconnection.
526
+ */
527
+ const startKeepAliveRequest = () => {
528
+ keepAliveReq = setInterval(() => {
529
+ if (!lastDateRecv) lastDateRecv = new Date()
530
+ const diff = Date.now() - lastDateRecv.getTime()
531
+ if (diff > keepAliveIntervalMs + 5000) {
532
+ void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
533
+ } else if (ws.isOpen) {
534
+ query({
535
+ tag: 'iq',
536
+ attrs: { id: generateMessageTag(), to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:p' },
537
+ content: [{ tag: 'ping', attrs: {} }]
538
+ }).catch((err) => logger.error({ trace: err.stack }, 'Error in sending keep alive'))
539
+ } else {
540
+ logger.warn('Keep alive called when WS not open')
541
+ }
542
+ }, keepAliveIntervalMs)
543
+ }
544
+
545
+ /**
546
+ * Tear down the socket.
547
+ * Awaits ws.close() so cleanup is deterministic before emitting connection.update.
548
+ * Runs all registered socketEndHandlers in order.
549
+ * Calls signalRepository.close() and ev.destroy() to prevent leaks.
550
+ */
551
+ const end = async (error) => {
552
+ if (closed) { logger.trace({ trace: error?.stack }, 'Connection already closed'); return }
553
+ closed = true
554
+ logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed')
555
+ clearInterval(keepAliveReq)
556
+ clearTimeout(qrTimer)
557
+ ws.removeAllListeners('close')
558
+ ws.removeAllListeners('open')
559
+ ws.removeAllListeners('message')
560
+ signalRepository.close?.()
561
+ if (!ws.isClosed && !ws.isClosing) {
562
+ try { await ws.close() } catch { }
563
+ }
564
+ for (const handler of socketEndHandlers) {
565
+ try { await handler(error) }
566
+ catch (err) { logger.error({ err }, 'error in socket end handler') }
567
+ }
568
+ ev.emit('connection.update', { connection: 'close', lastDisconnect: { error, date: new Date() } })
569
+ ev.removeAllListeners('connection.update')
570
+ ev.destroy()
571
+ }
572
+
573
+ const sendPassiveIq = (tag) =>
574
+ query({
575
+ tag: 'iq',
576
+ attrs: { to: S_WHATSAPP_NET, xmlns: 'passive', type: 'set' },
577
+ content: [{ tag, attrs: {} }]
578
+ })
579
+
580
+ const logout = async (msg) => {
581
+ const jid = authState.creds.me?.id
582
+ if (jid) {
583
+ await sendNode({
584
+ tag: 'iq',
585
+ attrs: { to: S_WHATSAPP_NET, type: 'set', id: generateMessageTag(), xmlns: 'md' },
586
+ content: [{ tag: 'remove-companion-device', attrs: { jid, reason: 'user_initiated' } }]
587
+ })
588
+ }
589
+ void end(new Boom(msg || 'Intentional Logout', { statusCode: DisconnectReason.loggedOut }))
590
+ }
591
+
592
+ /**
593
+ * Register a cleanup handler that will be awaited during end().
594
+ * Use for releasing external resources tied to this socket's lifetime.
595
+ */
596
+ const registerSocketEndHandler = (handler) => socketEndHandlers.push(handler)
597
+
598
+ // ─── Pairing ─────────────────────────────────────────────────────────────────
599
+
600
+ const requestPairingCode = async (phoneNumber, customPairingCode) => {
601
+ await waitForSocketOpen()
602
+ // Brief stabilisation delay — ensures the WS open event has fully propagated
603
+ await new Promise((resolve) => setTimeout(resolve, 500))
604
+ const pairingCode = customPairingCode ?? bytesToCrockford(randomBytes(5))
605
+ if (customPairingCode && customPairingCode?.length !== 8)
606
+ throw new Error('Custom pairing code must be exactly 8 chars')
607
+ authState.creds.pairingCode = pairingCode
608
+ authState.creds.me = { id: jidEncode(phoneNumber, 's.whatsapp.net'), name: '~' }
609
+ ev.emit('creds.update', authState.creds)
610
+ await sendNode({
611
+ tag: 'iq',
612
+ attrs: { to: S_WHATSAPP_NET, type: 'set', id: generateMessageTag(), xmlns: 'md' },
613
+ content: [{
614
+ tag: 'link_code_companion_reg',
615
+ attrs: { jid: authState.creds.me.id, stage: 'companion_hello', should_show_push_notification: 'true' },
616
+ content: [
617
+ { tag: 'link_code_pairing_wrapped_companion_ephemeral_pub', attrs: {}, content: await generatePairingKey() },
618
+ { tag: 'companion_server_auth_key_pub', attrs: {}, content: authState.creds.noiseKey.public },
619
+ { tag: 'companion_platform_id', attrs: {}, content: getCompanionPlatformId(browser) },
620
+ { tag: 'companion_platform_display', attrs: {}, content: `${browser[1]} (${browser[0]})` },
621
+ { tag: 'link_code_pairing_nonce', attrs: {}, content: '0' }
622
+ ]
623
+ }]
624
+ })
625
+ return authState.creds.pairingCode
626
+ }
627
+
628
+ async function generatePairingKey() {
629
+ const salt = randomBytes(32)
630
+ const randomIv = randomBytes(16)
631
+ const key = await derivePairingCodeKey(authState.creds.pairingCode, salt)
632
+ const ciphered = aesEncryptCTR(authState.creds.pairingEphemeralKeyPair.public, key, randomIv)
633
+ return Buffer.concat([salt, randomIv, ciphered])
634
+ }
635
+
636
+ // ─── Incoming message processing ────────────────────────────────────────────
637
+
638
+ const onMessageReceived = (data) => {
639
+ noise.decodeFrame(data, (frame) => {
640
+ lastDateRecv = new Date()
641
+ let anyTriggered = ws.emit('frame', frame)
642
+ if (!(frame instanceof Uint8Array)) {
643
+ const msgId = frame.attrs.id
644
+ if (logger.level === 'trace') logger.trace({ xml: binaryNodeToString(frame), msg: 'recv xml' })
645
+ anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered
646
+ const l0 = frame.tag
647
+ const l1 = frame.attrs || {}
648
+ const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''
649
+ for (const key of Object.keys(l1)) {
650
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
651
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
652
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
653
+ }
654
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
655
+ anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
656
+ if (!anyTriggered && logger.level === 'debug')
657
+ logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'Unhandled communication received')
658
+ }
659
+ })
660
+ }
661
+
662
+ // ─── WebSocket event bindings ────────────────────────────────────────────────
663
+
664
+ ws.on('message', onMessageReceived)
665
+
666
+ ws.on('open', async () => {
667
+ try { await validateConnection() }
668
+ catch (err) { logger.error({ err }, 'error in validating connection'); void end(err) }
669
+ })
670
+
671
+ ws.on('error', mapWebSocketError(end))
672
+
673
+ ws.on('close', () => void end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })))
674
+
675
+ ws.on('CB:xmlstreamend', () => {
676
+ logger.info('Stream ended by server')
677
+ if (!closed) void end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))
678
+ })
679
+
680
+ // ─── QR pairing ─────────────────────────────────────────────────────────────
681
+
682
+ ws.on('CB:iq,type:set,pair-device', async (stanza) => {
683
+ await sendNode({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'result', id: stanza.attrs.id } })
684
+ const pairDeviceNode = getBinaryNodeChild(stanza, 'pair-device')
685
+ const refNodes = getBinaryNodeChildren(pairDeviceNode, 'ref')
686
+ const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64')
687
+ const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
688
+ const advB64 = creds.advSecretKey
689
+ let qrMs = qrTimeout || 60000
690
+ const genPairQR = () => {
691
+ if (!ws.isOpen) return
692
+ const refNode = refNodes.shift()
693
+ if (!refNode) { void end(new Boom('QR refs attempts ended', { statusCode: DisconnectReason.timedOut })); return }
694
+ const ref = refNode.content.toString('utf-8')
695
+ // Use buildPairingQRData so the browser tuple is included in the QR payload
696
+ const qr = buildPairingQRData(ref, noiseKeyB64, identityKeyB64, advB64, browser)
697
+ ev.emit('connection.update', { qr })
698
+ qrTimer = setTimeout(genPairQR, qrMs)
699
+ qrMs = qrTimeout || 20000
700
+ }
701
+ genPairQR()
702
+ })
703
+
704
+ ws.on('CB:iq,,pair-success', async (stanza) => {
705
+ logger.debug('Pair success received')
706
+ try {
707
+ updateServerTimeOffset(stanza)
708
+ const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
709
+ logger.info({ me: updatedCreds.me, platform: updatedCreds.platform }, 'Pairing configured successfully')
710
+ ev.emit('creds.update', updatedCreds)
711
+ ev.emit('connection.update', { isNewLogin: true, qr: undefined })
712
+ await sendNode(reply)
713
+ void sendUnifiedSession()
714
+ } catch (error) {
715
+ logger.info({ trace: error.stack }, 'Error in pairing')
716
+ void end(error)
717
+ }
718
+ })
719
+
720
+ // ─── Login complete ──────────────────────────────────────────────────────────
721
+
722
+ ws.on('CB:success', async (node) => {
723
+ try {
724
+ updateServerTimeOffset(node)
725
+ await uploadPreKeysToServerIfRequired()
726
+ await sendPassiveIq('active')
727
+ try { await digestKeyBundle() }
728
+ catch (e) { logger.warn({ e }, 'failed to run digest after login') }
729
+ } catch (err) {
730
+ logger.warn({ err }, 'Failed to send initial passive IQ')
731
+ }
732
+ logger.info('✅ Opened connection to WhatsApp')
733
+ clearTimeout(qrTimer)
734
+ ev.emit('creds.update', { me: { ...authState.creds.me, lid: node.attrs.lid } })
735
+ ev.emit('connection.update', { connection: 'open' })
736
+ void sendUnifiedSession()
737
+
738
+ if (node.attrs.lid && authState.creds.me?.id) {
739
+ const myLID = node.attrs.lid
740
+ process.nextTick(async () => {
741
+ try {
742
+ const myPN = authState.creds.me.id
743
+ // Store own LID-PN mapping
744
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: myLID, pn: myPN }])
745
+ // Build device-list using index-based batching to avoid unbounded key growth
746
+ const { user, device } = jidDecode(myPN)
747
+ const currentBatch = await migrateIndexKey(authState.keys, 'device-list')
748
+ currentBatch[user] = [device?.toString() || '0']
749
+ const deviceKeys = Object.keys(currentBatch)
750
+ if (deviceKeys.length > BATCH_SIZE) {
751
+ deviceKeys.sort()
752
+ deviceKeys.slice(0, deviceKeys.length - BATCH_SIZE).forEach((k) => delete currentBatch[k])
753
+ }
754
+ await authState.keys.set({ 'device-list': { index: currentBatch } })
755
+ // Migrate own session from PN → LID
756
+ await signalRepository.migrateSession(myPN, myLID)
757
+ logger.info({ myPN, myLID }, 'Own LID session created successfully')
758
+ // Batch-migrate any remaining PN sessions to LID
759
+ if (signalRepository.migrateAllPNSessionsToLID) {
760
+ try {
761
+ const migrated = await signalRepository.migrateAllPNSessionsToLID()
762
+ if (migrated > 0) logger.info({ migrated }, 'Batch-migrated PN sessions to LID on connect')
763
+ } catch (migErr) {
764
+ logger.warn({ error: migErr }, 'Failed to batch-migrate PN sessions to LID')
765
+ }
766
+ }
767
+ } catch (error) {
768
+ logger.error({ error, lid: myLID }, 'Failed to create own LID session')
769
+ }
770
+ })
771
+ }
772
+ })
773
+
774
+ // ─── Stream / connection error handlers ─────────────────────────────────────
775
+
776
+ ws.on('CB:stream:error', (node) => {
777
+ const [reasonNode] = getAllBinaryNodeChildren(node)
778
+ logger.error({ reasonNode, fullErrorNode: node }, 'Stream errored out')
779
+ const { reason, statusCode } = getErrorCodeFromStreamError(node)
780
+ void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }))
781
+ })
782
+
783
+ ws.on('CB:failure', (node) => {
784
+ const reason = +(node.attrs.reason || 500)
785
+ void end(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }))
786
+ })
787
+
788
+ ws.on('CB:ib,,downgrade_webclient', () =>
789
+ void end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }))
790
+ )
791
+
792
+ ws.on('CB:ib,,offline_preview', async (node) => {
793
+ logger.info('Offline preview received', JSON.stringify(node))
794
+ await sendNode({ tag: 'ib', attrs: {}, content: [{ tag: 'offline_batch', attrs: { count: '100' } }] })
795
+ })
796
+
797
+ ws.on('CB:ib,,edge_routing', (node) => {
798
+ const edgeRoutingNode = getBinaryNodeChild(node, 'edge_routing')
799
+ const routingInfo = getBinaryNodeChild(edgeRoutingNode, 'routing_info')
800
+ if (routingInfo?.content) {
801
+ authState.creds.routingInfo = Buffer.from(routingInfo.content)
802
+ ev.emit('creds.update', authState.creds)
803
+ }
804
+ })
805
+
806
+ // ─── Buffering & offline notifications ──────────────────────────────────────
807
+
808
+ process.nextTick(() => {
809
+ if (creds.me?.id) { ev.buffer(); didStartBuffer = true }
810
+ ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false, qr: undefined })
811
+ })
812
+
813
+ ws.on('CB:ib,,offline', (node) => {
814
+ const child = getBinaryNodeChild(node, 'offline')
815
+ const offlineNotifs = +(child?.attrs.count || 0)
816
+ logger.info(`Handled ${offlineNotifs} offline messages/notifications`)
817
+ if (didStartBuffer) { ev.flush(); logger.trace('Flushed events for initial buffer') }
818
+ ev.emit('connection.update', { receivedPendingNotifications: true })
819
+ })
820
+
821
+ // ─── Creds sync ──────────────────────────────────────────────────────────────
822
+
823
+ ev.on('creds.update', (update) => {
824
+ const name = update.me?.name
825
+ if (creds.me?.name !== name) {
826
+ logger.debug({ name }, 'Updated pushName')
827
+ sendNode({ tag: 'presence', attrs: { name } }).catch((err) =>
828
+ logger.warn({ trace: err.stack }, 'Error in sending presence update on name change')
829
+ )
830
+ }
831
+ Object.assign(creds, update)
832
+ })
833
+
834
+ // ─── Public API ──────────────────────────────────────────────────────────────
835
+
836
+ return {
837
+ type: 'md',
838
+ ws,
839
+ ev,
840
+ authState: { creds, keys },
841
+ signalRepository,
842
+ get user() { return authState.creds.me },
843
+ generateMessageTag,
844
+ query,
845
+ waitForMessage,
846
+ waitForSocketOpen,
847
+ sendRawMessage,
848
+ sendNode,
849
+ logout,
850
+ end,
851
+ registerSocketEndHandler,
852
+ onUnexpectedError,
853
+ uploadPreKeys,
854
+ uploadPreKeysToServerIfRequired,
855
+ digestKeyBundle,
856
+ rotateSignedPreKey,
857
+ updateServerTimeOffset,
858
+ sendUnifiedSession,
859
+ requestPairingCode,
860
+ wamBuffer: publicWAMBuffer,
861
+ waitForConnectionUpdate: bindWaitForConnectionUpdate(ev),
862
+ sendWAMBuffer,
863
+ executeUSyncQuery,
864
+ onWhatsApp,
865
+ fetchAccountReachoutTimelock,
866
+ fetchNewChatMessageCap,
867
+ listener: (eventName) => {
868
+ if (typeof ev.listenerCount === 'function') return ev.listenerCount(eventName)
869
+ if (typeof ev.listener === 'function') return ev.listener(eventName)?.length || 0
870
+ return 0
871
+ }
872
+ }
1202
873
  }