From 4f623b49427f5a955a019bf2766406451ca54b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:12:05 +0000 Subject: [PATCH 01/12] feat(defaults): apply RSocket's battle-tested configurations - Add DEFAULT_CACHE_MAX_KEYS with limits per store type (prevents memory leaks) - Add RETRY_BACKOFF_DELAYS [1s, 2s, 5s, 10s, 20s] for exponential backoff - Add RETRY_JITTER_FACTOR (0.15) to prevent thundering herd - Change INITIAL_PREKEY_COUNT from 812 to 30 (safer for rate limiting) - Change MIN_UPLOAD_INTERVAL from 5s to 60s (avoids rate limiting) - Change syncFullHistory default to false (conservative approach) Based on RSocket fork's production-tested configuration. --- src/Defaults/index.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 4c8f97a9..ea0c3c5d 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -60,7 +60,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { fireInitQueries: true, auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, - syncFullHistory: true, + // Conservative default - set to true only if you need full message history + syncFullHistory: false, patchMessageBeforeSending: msg => msg, shouldSyncHistoryMessage: () => true, shouldIgnoreJid: () => false, @@ -123,14 +124,45 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MIN_PREKEY_COUNT = 5 -export const INITIAL_PREKEY_COUNT = 812 +// Conservative prekey count (upstream uses 812, but 30 is safer for rate limiting) +export const INITIAL_PREKEY_COUNT = 30 export const UPLOAD_TIMEOUT = 30000 // 30 seconds -export const MIN_UPLOAD_INTERVAL = 5000 // 5 seconds minimum between uploads +// Conservative upload interval to avoid rate limiting (was 5000) +export const MIN_UPLOAD_INTERVAL = 60_000 // 60 seconds minimum between uploads +/** + * Cache TTL configuration (in seconds) + */ export const DEFAULT_CACHE_TTLS = { SIGNAL_STORE: 5 * 60, // 5 minutes MSG_RETRY: 60 * 60, // 1 hour CALL_OFFER: 5 * 60, // 5 minutes USER_DEVICES: 5 * 60 // 5 minutes } + +/** + * Maximum cache keys per store type - prevents memory leaks + * Based on RSocket's battle-tested configuration + */ +export const DEFAULT_CACHE_MAX_KEYS = { + SIGNAL_STORE: 10_000, + MSG_RETRY: 10_000, + CALL_OFFER: 500, + USER_DEVICES: 5_000, + PLACEHOLDER_RESEND: 5_000, + LID_PER_SOCKET: 2_000, + LID_GLOBAL: 10_000 +} + +/** + * Retry configuration with exponential backoff + * Delays in milliseconds: 1s, 2s, 5s, 10s, 20s + */ +export const RETRY_BACKOFF_DELAYS = [1000, 2000, 5000, 10000, 20000] + +/** + * Jitter factor for retry delays (0.15 = ±15% randomization) + * Helps prevent thundering herd problem + */ +export const RETRY_JITTER_FACTOR = 0.15 From 3afb8b80c53f0a925aa496262ce7d98229721911 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:19:22 +0000 Subject: [PATCH 02/12] feat(socket): add configurable maxListeners to prevent memory leaks Based on RSocket's battle-tested configuration: - Add maxWebSocketListeners config option (default: 20) - 8 base WS events + 10 dynamic listeners + 2 buffer slots - Add maxSocketClientListeners config option (default: 50) - Replace dangerous setMaxListeners(0) with configurable limits - Add warning log if user explicitly sets limit to 0 BREAKING: Previous behavior used setMaxListeners(0) which removed all limits. Now defaults to safe limits but can be overridden via config. --- src/Defaults/index.ts | 6 +++++- src/Socket/Client/types.ts | 8 +++++++- src/Socket/Client/websocket.ts | 8 +++++++- src/Types/Socket.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index ea0c3c5d..006f9bb4 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -80,7 +80,11 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { cachedGroupMetadata: async () => undefined, makeSignalRepository: makeLibSignalRepository, // Circuit breaker configuration - enableCircuitBreaker: true + enableCircuitBreaker: true, + // Listener limits (memory leak prevention) + // 8 base WS events + 10 dynamic listeners + 2 buffer slots + maxWebSocketListeners: 20, + maxSocketClientListeners: 50 } export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { diff --git a/src/Socket/Client/types.ts b/src/Socket/Client/types.ts index 3a6ee818..cc7c7024 100644 --- a/src/Socket/Client/types.ts +++ b/src/Socket/Client/types.ts @@ -13,7 +13,13 @@ export abstract class AbstractSocketClient extends EventEmitter { public config: SocketConfig ) { super() - this.setMaxListeners(0) + // Set max listeners from config (default: 50) + // WARNING: 0 disables limit and allows potential memory leaks + const maxListeners = this.config.maxSocketClientListeners ?? 50 + if (maxListeners === 0) { + this.config.logger?.warn('SocketClient setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!') + } + this.setMaxListeners(maxListeners) } abstract connect(): void diff --git a/src/Socket/Client/websocket.ts b/src/Socket/Client/websocket.ts index 6aab1e3c..59632cd2 100644 --- a/src/Socket/Client/websocket.ts +++ b/src/Socket/Client/websocket.ts @@ -31,7 +31,13 @@ export class WebSocketClient extends AbstractSocketClient { agent: this.config.agent }) - this.socket.setMaxListeners(0) + // Set max listeners from config (default: 20) + // WARNING: 0 disables limit and allows potential memory leaks + const maxListeners = this.config.maxWebSocketListeners ?? 20 + if (maxListeners === 0) { + this.config.logger?.warn('WebSocket setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!') + } + this.socket.setMaxListeners(maxListeners) const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'] diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 62ab06b5..5d98c075 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -165,4 +165,19 @@ export type SocketConfig = { /** Circuit breaker configuration for message operations */ messageCircuitBreaker?: Partial + + // === Listener Limits (Memory Leak Prevention) === + + /** + * Max WebSocket event listeners (default: 20) + * Calculated as: 8 base WS events + 10 dynamic listeners + 2 buffer slots + * WARNING: Setting to 0 disables limit and allows potential memory leaks! + */ + maxWebSocketListeners?: number + + /** + * Max SocketClient event listeners (default: 50) + * WARNING: Setting to 0 disables limit and allows potential memory leaks! + */ + maxSocketClientListeners?: number } From 4a2731fbbd6e85c87995cac0259f3f5cc5a18a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 05:33:03 +0000 Subject: [PATCH 03/12] fix(defaults): address PR #10 code review feedback - Integrate RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR into retry-utils.ts - Add retryConfigs.rsocket using the default delays - Add getRetryDelayWithJitter() helper function - Add getAllRetryDelaysWithJitter() for planning - Document DEFAULT_CACHE_MAX_KEYS with usage example - Adjust INITIAL_PREKEY_COUNT from 30 to 200 (moderate value) - Adjust MIN_UPLOAD_INTERVAL from 60s to 10s (balance rate limiting/responsiveness) - Revert syncFullHistory to true (avoid breaking change) --- src/Defaults/index.ts | 17 +++++++++++------ src/Utils/retry-utils.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 006f9bb4..820737ff 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -60,8 +60,8 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { fireInitQueries: true, auth: undefined as unknown as AuthenticationState, markOnlineOnConnect: true, - // Conservative default - set to true only if you need full message history - syncFullHistory: false, + // Set to false if you don't need full message history (reduces bandwidth/storage) + syncFullHistory: true, patchMessageBeforeSending: msg => msg, shouldSyncHistoryMessage: () => true, shouldIgnoreJid: () => false, @@ -128,12 +128,12 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MIN_PREKEY_COUNT = 5 -// Conservative prekey count (upstream uses 812, but 30 is safer for rate limiting) -export const INITIAL_PREKEY_COUNT = 30 +// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability) +export const INITIAL_PREKEY_COUNT = 200 export const UPLOAD_TIMEOUT = 30000 // 30 seconds -// Conservative upload interval to avoid rate limiting (was 5000) -export const MIN_UPLOAD_INTERVAL = 60_000 // 60 seconds minimum between uploads +// Moderate upload interval to balance rate limiting and responsiveness (was 5000) +export const MIN_UPLOAD_INTERVAL = 10_000 // 10 seconds minimum between uploads /** * Cache TTL configuration (in seconds) @@ -148,6 +148,11 @@ export const DEFAULT_CACHE_TTLS = { /** * Maximum cache keys per store type - prevents memory leaks * Based on RSocket's battle-tested configuration + * + * Usage: Use these limits when initializing LRU caches to prevent unbounded growth + * Example: + * import { DEFAULT_CACHE_MAX_KEYS } from './Defaults' + * const cache = new LRUCache({ max: DEFAULT_CACHE_MAX_KEYS.SIGNAL_STORE }) */ export const DEFAULT_CACHE_MAX_KEYS = { SIGNAL_STORE: 10_000, diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts index 68edcb88..72677105 100644 --- a/src/Utils/retry-utils.ts +++ b/src/Utils/retry-utils.ts @@ -16,6 +16,7 @@ import { EventEmitter } from 'events' import { metrics } from './prometheus-metrics.js' import type { CircuitBreaker } from './circuit-breaker.js' +import { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Defaults/index.js' /** * Backoff strategies @@ -631,6 +632,41 @@ export const retryConfigs = { jitter: 0.1, shouldRetry: retryPredicates.onNetworkError, }, + + /** + * RSocket-style retry (uses RETRY_BACKOFF_DELAYS from Defaults) + * Delays: 1s, 2s, 5s, 10s, 20s with jitter + */ + rsocket: { + maxAttempts: RETRY_BACKOFF_DELAYS.length, + baseDelay: RETRY_BACKOFF_DELAYS[0], + maxDelay: RETRY_BACKOFF_DELAYS[RETRY_BACKOFF_DELAYS.length - 1], + backoffStrategy: 'exponential' as const, + jitter: RETRY_JITTER_FACTOR, + }, +} + +/** + * Get retry delay with jitter applied + * Uses RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR from Defaults + * + * @param attempt - Current attempt number (1-based) + * @returns Delay in ms with jitter applied + */ +export function getRetryDelayWithJitter(attempt: number): number { + const index = Math.min(Math.max(attempt - 1, 0), RETRY_BACKOFF_DELAYS.length - 1) + const baseDelay = RETRY_BACKOFF_DELAYS[index] ?? RETRY_BACKOFF_DELAYS[0] ?? 1000 + const jitterRange = baseDelay * RETRY_JITTER_FACTOR + const jitter = (Math.random() * 2 - 1) * jitterRange // ±15% + return Math.round(baseDelay + jitter) +} + +/** + * Get all retry delays with jitter for planning + * @returns Array of delays with jitter applied + */ +export function getAllRetryDelaysWithJitter(): number[] { + return RETRY_BACKOFF_DELAYS.map((_, i) => getRetryDelayWithJitter(i + 1)) } export default retry From 4bdcedf96bbc0e1afbfde20a3e2b0fed050ab278 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:35:35 +0200 Subject: [PATCH 04/12] chore: update WhatsApp Web version (#2269) Co-authored-by: github-actions[bot] --- src/Defaults/baileys-version.json | 2 +- src/Defaults/index.ts | 2 +- src/Utils/generics.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 2f3f16d8..edf42218 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version":[2,3000,1027934701]} +{"version":[2,3000,1032141294]} diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 3bc955dd..e46035d8 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -4,7 +4,7 @@ import type { AuthenticationState, SocketConfig, WAVersion } from '../Types' import { Browsers } from '../Utils/browser-utils' import logger from '../Utils/logger' -const version = [2, 3000, 1029027441] +const version = [2, 3000, 1032141294] export const UNAUTHORIZED_CODES = [401, 403, 419] diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index d308e62e..60788954 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -1,7 +1,7 @@ import { Boom } from '@hapi/boom' import { createHash, randomBytes } from 'crypto' import { proto } from '../../WAProto/index.js' -const baileysVersion = [2, 3000, 1027934701] +const baileysVersion = [2, 3000, 1032141294] import type { BaileysEventEmitter, BaileysEventMap, From 5887551d682ca11ad3196d62c83d0d39ac24c760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:37:04 -0300 Subject: [PATCH 05/12] fix: resolve race condition in decodeFrame handling and improve encryption integrity (#2182) * fix: resolve race condition in decodeFrame handling and improve encryption integrity * chore: pr feedback --- src/Utils/noise-handler.ts | 216 ++++++++------ src/__tests__/Utils/noise-handler.test.ts | 339 ++++++++++++++++++++++ 2 files changed, 472 insertions(+), 83 deletions(-) create mode 100644 src/__tests__/Utils/noise-handler.test.ts diff --git a/src/Utils/noise-handler.ts b/src/Utils/noise-handler.ts index 779a4bab..feb8a6c2 100644 --- a/src/Utils/noise-handler.ts +++ b/src/Utils/noise-handler.ts @@ -7,13 +7,48 @@ import { decodeBinaryNode } from '../WABinary' import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto' import type { ILogger } from './logger' -const generateIV = (counter: number) => { - const iv = new ArrayBuffer(12) - new DataView(iv).setUint32(8, counter) +const IV_LENGTH = 12 +const EMPTY_BUFFER = Buffer.alloc(0) + +const generateIV = (counter: number): Uint8Array => { + const iv = new ArrayBuffer(IV_LENGTH) + new DataView(iv).setUint32(8, counter) return new Uint8Array(iv) } +class TransportState { + private readCounter = 0 + private writeCounter = 0 + + private readonly iv = new Uint8Array(IV_LENGTH) + + constructor( + private readonly encKey: Buffer, + private readonly decKey: Buffer + ) {} + + encrypt(plaintext: Uint8Array): Uint8Array { + const c = this.writeCounter++ + this.iv[8] = (c >>> 24) & 0xff + this.iv[9] = (c >>> 16) & 0xff + this.iv[10] = (c >>> 8) & 0xff + this.iv[11] = c & 0xff + + return aesEncryptGCM(plaintext, this.encKey, this.iv, EMPTY_BUFFER) + } + + decrypt(ciphertext: Uint8Array): Buffer { + const c = this.readCounter++ + this.iv[8] = (c >>> 24) & 0xff + this.iv[9] = (c >>> 16) & 0xff + this.iv[10] = (c >>> 8) & 0xff + this.iv[11] = c & 0xff + + return aesDecryptGCM(ciphertext, this.decKey, this.iv, EMPTY_BUFFER) as Buffer + } +} + export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publicKey }, NOISE_HEADER, @@ -27,40 +62,63 @@ export const makeNoiseHandler = ({ }) => { logger = logger.child({ class: 'ns' }) + const data = Buffer.from(NOISE_MODE) + let hash = data.byteLength === 32 ? data : sha256(data) + let salt = hash + let encKey = hash + let decKey = hash + let counter = 0 + let sentIntro = false + + let inBytes: Buffer = Buffer.alloc(0) + + let transport: TransportState | null = null + let isWaitingForTransport = false + let pendingOnFrame: ((buff: Uint8Array | BinaryNode) => void) | null = null + + let introHeader: Buffer + if (routingInfo) { + introHeader = Buffer.alloc(7 + routingInfo.byteLength + NOISE_HEADER.length) + introHeader.write('ED', 0, 'utf8') + introHeader.writeUint8(0, 2) + introHeader.writeUint8(1, 3) + introHeader.writeUint8(routingInfo.byteLength >> 16, 4) + introHeader.writeUint16BE(routingInfo.byteLength & 65535, 5) + introHeader.set(routingInfo, 7) + introHeader.set(NOISE_HEADER, 7 + routingInfo.byteLength) + } else { + introHeader = Buffer.from(NOISE_HEADER) + } + const authenticate = (data: Uint8Array) => { - if (!isFinished) { + if (!transport) { hash = sha256(Buffer.concat([hash, data])) } } - const encrypt = (plaintext: Uint8Array) => { - const result = aesEncryptGCM(plaintext, encKey, generateIV(writeCounter), hash) - - writeCounter += 1 + const encrypt = (plaintext: Uint8Array): Uint8Array => { + if (transport) { + return transport.encrypt(plaintext) + } + const result = aesEncryptGCM(plaintext, encKey, generateIV(counter++), hash) authenticate(result) return result } - const decrypt = (ciphertext: Uint8Array) => { - // before the handshake is finished, we use the same counter - // after handshake, the counters are different - const iv = generateIV(isFinished ? readCounter : writeCounter) - const result = aesDecryptGCM(ciphertext, decKey, iv, hash) - - if (isFinished) { - readCounter += 1 - } else { - writeCounter += 1 + const decrypt = (ciphertext: Uint8Array): Uint8Array => { + if (transport) { + return transport.decrypt(ciphertext) } + const result = aesDecryptGCM(ciphertext, decKey, generateIV(counter++), hash) authenticate(ciphertext) return result } const localHKDF = async (data: Uint8Array) => { const key = await hkdf(Buffer.from(data), 64, { salt, info: '' }) - return [key.slice(0, 32), key.slice(32)] + return [key.subarray(0, 32), key.subarray(32)] } const mixIntoKey = async (data: Uint8Array) => { @@ -68,31 +126,49 @@ export const makeNoiseHandler = ({ salt = write! encKey = read! decKey = read! - readCounter = 0 - writeCounter = 0 + counter = 0 } const finishInit = async () => { + isWaitingForTransport = true const [write, read] = await localHKDF(new Uint8Array(0)) - encKey = write! - decKey = read! - hash = Buffer.from([]) - readCounter = 0 - writeCounter = 0 - isFinished = true + transport = new TransportState(write!, read!) + isWaitingForTransport = false + + logger.trace('Noise handler transitioned to Transport state') + + if (pendingOnFrame) { + logger.trace({ length: inBytes.length }, 'Flushing buffered frames after transport ready') + await processData(pendingOnFrame) + pendingOnFrame = null + } } - const data = Buffer.from(NOISE_MODE) - let hash = data.byteLength === 32 ? data : sha256(data) - let salt = hash - let encKey = hash - let decKey = hash - let readCounter = 0 - let writeCounter = 0 - let isFinished = false - let sentIntro = false + const processData = async (onFrame: (buff: Uint8Array | BinaryNode) => void) => { + let size: number | undefined - let inBytes = Buffer.alloc(0) + while (true) { + if (inBytes.length < 3) return + + size = (inBytes[0]! << 16) | (inBytes[1]! << 8) | inBytes[2]! + + if (inBytes.length < size + 3) return + + let frame: Uint8Array | BinaryNode = inBytes.subarray(3, size + 3) + inBytes = inBytes.subarray(size + 3) + + if (transport) { + const result = transport.decrypt(frame) + frame = await decodeBinaryNode(result) + } + + if (logger.level === 'trace') { + logger.trace({ msg: (frame as BinaryNode)?.attrs?.id }, 'recv frame') + } + + onFrame(frame) + } + } authenticate(NOISE_HEADER) authenticate(publicKey) @@ -152,67 +228,41 @@ export const makeNoiseHandler = ({ return keyEnc }, encodeFrame: (data: Buffer | Uint8Array) => { - if (isFinished) { - data = encrypt(data) + if (transport) { + data = transport.encrypt(data) } - let header: Buffer - - if (routingInfo) { - header = Buffer.alloc(7) - header.write('ED', 0, 'utf8') - header.writeUint8(0, 2) - header.writeUint8(1, 3) - header.writeUint8(routingInfo.byteLength >> 16, 4) - header.writeUint16BE(routingInfo.byteLength & 65535, 5) - header = Buffer.concat([header, routingInfo, NOISE_HEADER]) - } else { - header = Buffer.from(NOISE_HEADER) - } - - const introSize = sentIntro ? 0 : header.length - const frame = Buffer.alloc(introSize + 3 + data.byteLength) + const dataLen = data.byteLength + const introSize = sentIntro ? 0 : introHeader.length + const frame = Buffer.allocUnsafe(introSize + 3 + dataLen) if (!sentIntro) { - frame.set(header) + frame.set(introHeader) sentIntro = true } - frame.writeUInt8(data.byteLength >> 16, introSize) - frame.writeUInt16BE(65535 & data.byteLength, introSize + 1) + frame[introSize] = (dataLen >>> 16) & 0xff + frame[introSize + 1] = (dataLen >>> 8) & 0xff + frame[introSize + 2] = dataLen & 0xff + frame.set(data, introSize + 3) return frame }, decodeFrame: async (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => { - // the binary protocol uses its own framing mechanism - // on top of the WS frames - // so we get this data and separate out the frames - const getBytesSize = () => { - if (inBytes.length >= 3) { - return (inBytes.readUInt8() << 16) | inBytes.readUInt16BE(1) - } + if (isWaitingForTransport) { + inBytes = Buffer.concat([inBytes, newData]) + pendingOnFrame = onFrame + return } - inBytes = Buffer.concat([inBytes, newData]) - - logger.trace(`recv ${newData.length} bytes, total recv ${inBytes.length} bytes`) - - let size = getBytesSize() - while (size && inBytes.length >= size + 3) { - let frame: Uint8Array | BinaryNode = inBytes.slice(3, size + 3) - inBytes = inBytes.slice(size + 3) - - if (isFinished) { - const result = decrypt(frame) - frame = await decodeBinaryNode(result) - } - - logger.trace({ msg: (frame as BinaryNode)?.attrs?.id }, 'recv frame') - - onFrame(frame) - size = getBytesSize() + if (inBytes.length === 0) { + inBytes = Buffer.from(newData) + } else { + inBytes = Buffer.concat([inBytes, newData]) } + + await processData(onFrame) } } } diff --git a/src/__tests__/Utils/noise-handler.test.ts b/src/__tests__/Utils/noise-handler.test.ts new file mode 100644 index 00000000..76ad19d6 --- /dev/null +++ b/src/__tests__/Utils/noise-handler.test.ts @@ -0,0 +1,339 @@ +import { jest } from '@jest/globals' +import { NOISE_WA_HEADER } from '../../Defaults' +import { Curve } from '../../Utils/crypto' +import { makeNoiseHandler } from '../../Utils/noise-handler' +import type { BinaryNode } from '../../WABinary/types' + +// Create a mock logger +const createMockLogger = () => ({ + child: jest.fn().mockReturnThis(), + trace: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + level: 'trace' +}) + +// Helper to create a frame with length prefix +const createFrame = (payload: Buffer) => { + const frame = Buffer.alloc(3 + payload.length) + frame.writeUInt8(payload.length >> 16, 0) + frame.writeUInt16BE(payload.length & 0xffff, 1) + payload.copy(frame, 3) + return frame +} + +describe('Noise Handler', () => { + describe('decodeFrame with multiple frames in buffer', () => { + it('should process multiple unencrypted frames in single buffer', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + const payload1 = Buffer.from([1, 2, 3, 4, 5]) + const payload2 = Buffer.from([6, 7, 8, 9, 10]) + + const frame1 = createFrame(payload1) + const frame2 = createFrame(payload2) + + const combinedBuffer = Buffer.concat([frame1, frame2]) + + const receivedFrames: Buffer[] = [] + const onFrame = (frame: Uint8Array | BinaryNode) => { + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + await handler.decodeFrame(combinedBuffer, onFrame) + + expect(receivedFrames).toHaveLength(2) + expect(receivedFrames[0]).toEqual(payload1) + expect(receivedFrames[1]).toEqual(payload2) + }) + + it('should handle frames split across multiple decodeFrame calls', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + const payload = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + const frame = createFrame(payload) + + const receivedFrames: Buffer[] = [] + const onFrame = (frame: Uint8Array | BinaryNode) => { + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + // Split the frame across two calls + const part1 = frame.slice(0, 5) + const part2 = frame.slice(5) + + await handler.decodeFrame(part1, onFrame) + expect(receivedFrames).toHaveLength(0) + + await handler.decodeFrame(part2, onFrame) + expect(receivedFrames).toHaveLength(1) + expect(receivedFrames[0]).toEqual(payload) + }) + + it('should correctly process frames when callback triggers async operations', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + const payload1 = Buffer.from([1, 2, 3]) + const payload2 = Buffer.from([4, 5, 6]) + + const combinedBuffer = Buffer.concat([createFrame(payload1), createFrame(payload2)]) + + const receivedFrames: Buffer[] = [] + const callbackOrder: number[] = [] + + const onFrame = (frame: Uint8Array | BinaryNode) => { + const frameNum = receivedFrames.length + 1 + callbackOrder.push(frameNum) + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + await handler.decodeFrame(combinedBuffer, onFrame) + + expect(receivedFrames).toHaveLength(2) + expect(callbackOrder).toEqual([1, 2]) + expect(receivedFrames[0]).toEqual(payload1) + expect(receivedFrames[1]).toEqual(payload2) + }) + }) + + describe('encrypted frame handling', () => { + it('should encrypt and verify frame structure', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + await handler.finishInit() + + const payload = Buffer.from('test payload') + const encoded = handler.encodeFrame(payload) + + expect(encoded.length).toBeGreaterThan(payload.length + 3) + + const encoded2 = handler.encodeFrame(Buffer.from('second payload')) + expect(encoded2.slice(0, 3)).toEqual(Buffer.from([0, 0, encoded2.length - 3])) + }) + + it('should produce different ciphertext for same plaintext due to counter', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + await handler.finishInit() + + const payload = Buffer.from('same payload') + + const encrypted1 = handler.encrypt(payload) + const encrypted2 = handler.encrypt(payload) + const encrypted3 = handler.encrypt(payload) + + expect(encrypted1).not.toEqual(encrypted2) + expect(encrypted2).not.toEqual(encrypted3) + expect(encrypted1).not.toEqual(encrypted3) + }) + }) + + describe('race condition scenario - concurrent decodeFrame calls', () => { + it('should handle concurrent decodeFrame calls without corrupting inBytes buffer', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + // Create multiple frames + const payloads = Array.from({ length: 5 }, (_, i) => Buffer.from(`payload-${i}`)) + const frames = payloads.map(createFrame) + + const receivedFrames: Buffer[] = [] + const onFrame = (frame: Uint8Array | BinaryNode) => { + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + // Simulate concurrent calls (multiple WebSocket messages arriving rapidly) + // This tests the shared inBytes buffer handling + await Promise.all(frames.map(frame => handler.decodeFrame(frame, onFrame))) + + // All frames should be received + expect(receivedFrames).toHaveLength(5) + + // Verify all payloads are present (order may vary due to concurrency) + const receivedPayloads = receivedFrames.map(f => f.toString()) + payloads.forEach(p => { + expect(receivedPayloads).toContain(p.toString()) + }) + }) + + it('should maintain counter integrity with many frames in single buffer', async () => { + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + // Create 10 frames to stress test the while loop + const payloads = Array.from({ length: 10 }, (_, i) => Buffer.from(`frame-${i}-payload-data`)) + + const combinedBuffer = Buffer.concat(payloads.map(createFrame)) + + const receivedFrames: Buffer[] = [] + const onFrame = (frame: Uint8Array | BinaryNode) => { + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + await handler.decodeFrame(combinedBuffer, onFrame) + + expect(receivedFrames).toHaveLength(10) + payloads.forEach((payload, i) => { + expect(receivedFrames[i]).toEqual(payload) + }) + }) + }) + + describe('encrypted frame race condition', () => { + it('should produce different ciphertext for same plaintext due to counter', async () => { + // Verify that encryption uses incrementing counters + + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + await handler.finishInit() + + const payload1 = Buffer.from('message-1') + const payload2 = Buffer.from('message-2') + + const encrypted1 = handler.encrypt(payload1) + const encrypted2 = handler.encrypt(payload2) + + expect(encrypted1.length).toBe(payload1.length + 16) // +16 for GCM tag + expect(encrypted2.length).toBe(payload2.length + 16) + + // The encrypted data should be different (different counters used) + expect(encrypted1).not.toEqual(encrypted2) + }) + + it('should serialize concurrent decodeFrame calls (fix for race condition)', async () => { + // This test verifies that the lock mechanism correctly serializes + // concurrent decodeFrame calls, preventing race conditions + + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + const payload1 = Buffer.from('first') + const payload2 = Buffer.from('second') + const payload3 = Buffer.from('third') + + const frame1 = createFrame(payload1) + const frame2 = createFrame(payload2) + const frame3 = createFrame(payload3) + + const receivedOrder: string[] = [] + + const onFrame = (frame: Uint8Array | BinaryNode) => { + const content = Buffer.from(frame as Uint8Array).toString() + receivedOrder.push(content) + } + + // Start all three decodeFrame calls "simultaneously" + // With the lock fix, they should be processed in order + const p1 = handler.decodeFrame(frame1, onFrame) + const p2 = handler.decodeFrame(frame2, onFrame) + const p3 = handler.decodeFrame(frame3, onFrame) + + await Promise.all([p1, p2, p3]) + + // With serialization, frames should be received in the order + // the decodeFrame calls were made + expect(receivedOrder).toHaveLength(3) + expect(receivedOrder[0]).toBe('first') + expect(receivedOrder[1]).toBe('second') + expect(receivedOrder[2]).toBe('third') + }) + + it('should maintain frame order with interleaved partial frames after fix', async () => { + // This test verifies that partial frames from different sources + // are correctly reassembled when calls are serialized + + const keyPair = Curve.generateKeyPair() + const logger = createMockLogger() + + const handler = makeNoiseHandler({ + keyPair, + NOISE_HEADER: NOISE_WA_HEADER, + logger: logger as any + }) + + // Create a single frame split into parts + const payload = Buffer.from('complete-message-content') + const frame = createFrame(payload) + + const part1 = frame.slice(0, 10) + const part2 = frame.slice(10) + + const receivedFrames: Buffer[] = [] + const onFrame = (frame: Uint8Array | BinaryNode) => { + receivedFrames.push(Buffer.from(frame as Uint8Array)) + } + + // With serialization, these should be processed in order + // and the frame should be correctly reassembled + await handler.decodeFrame(part1, onFrame) + expect(receivedFrames).toHaveLength(0) // Not complete yet + + await handler.decodeFrame(part2, onFrame) + expect(receivedFrames).toHaveLength(1) + expect(receivedFrames[0]).toEqual(payload) + }) + }) +}) From 32134a870eafe2351abdcb6c076353387b38346c Mon Sep 17 00:00:00 2001 From: Luiz Braga Date: Tue, 20 Jan 2026 07:37:34 -0300 Subject: [PATCH 06/12] chore: Add messageTimestamp to message updates in messages-recv when receiving a message status update (#2277) --- src/Socket/messages-recv.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index c89d1720..95957134 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -37,6 +37,7 @@ import { MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, + toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey @@ -1097,7 +1098,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { 'messages.update', ids.map(id => ({ key: { ...key, id }, - update: { status } + update: { status, messageTimestamp: toNumber(+(attrs.t ?? 0)) } })) ) } From a89736f89dd13b8580b4c8fb8cc884c855ba5c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:39:29 -0300 Subject: [PATCH 07/12] fix: extract LID-PN mappings from history sync phoneNumberToLidMappings (#2268) --- src/Types/Events.ts | 4 +- src/Utils/history.ts | 10 ++- src/Utils/process-message.ts | 10 +++ src/__tests__/Utils/history.test.ts | 94 +++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/Utils/history.test.ts diff --git a/src/Types/Events.ts b/src/Types/Events.ts index e4a73f2c..bf0d1d07 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -1,6 +1,6 @@ import type { Boom } from '@hapi/boom' import { proto } from '../../WAProto/index.js' -import type { AuthenticationCreds } from './Auth' +import type { AuthenticationCreds, LIDMapping } from './Auth' import type { WACallEvent } from './Call' import type { Chat, ChatUpdate, PresenceData } from './Chat' import type { Contact } from './Contact' @@ -36,7 +36,7 @@ export type BaileysEventMap = { 'chats.upsert': Chat[] /** update the given chats */ 'chats.update': ChatUpdate[] - 'lid-mapping.update': { lid: string; pn: string } + 'lid-mapping.update': LIDMapping /** delete chats with given ID */ 'chats.delete': string[] /** presence of contact in a chat updated */ diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 7adbe09c..b8c1a504 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -1,7 +1,7 @@ import { promisify } from 'util' import { inflate } from 'zlib' import { proto } from '../../WAProto/index.js' -import type { Chat, Contact, WAMessage } from '../Types' +import type { Chat, Contact, LIDMapping, WAMessage } from '../Types' import { WAMessageStubType } from '../Types' import { toNumber } from './generics' import { normalizeMessageContent } from './messages' @@ -29,6 +29,13 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { const messages: WAMessage[] = [] const contacts: Contact[] = [] const chats: Chat[] = [] + // Extract LID-PN mappings for all sync types + const lidPnMappings: LIDMapping[] = [] + for (const m of item.phoneNumberToLidMappings || []) { + if (m.lidJid && m.pnJid) { + lidPnMappings.push({ lid: m.lidJid, pn: m.pnJid }) + } + } switch (item.syncType) { case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP: @@ -87,6 +94,7 @@ export const processHistoryMessage = (item: proto.IHistorySync) => { chats, contacts, messages, + lidPnMappings, syncType: item.syncType, progress: item.progress } diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 975bee05..6817fb3b 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -287,6 +287,16 @@ const processMessage = async ( const data = await downloadAndProcessHistorySyncNotification(histNotification, options) + // Emit LID-PN mappings from history sync + // This is how WhatsApp Web learns mappings for chats with non-contacts + if (data.lidPnMappings?.length) { + logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync') + // eslint-disable-next-line max-depth + for (const mapping of data.lidPnMappings) { + ev.emit('lid-mapping.update', mapping) + } + } + ev.emit('messaging-history.set', { ...data, isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined, diff --git a/src/__tests__/Utils/history.test.ts b/src/__tests__/Utils/history.test.ts new file mode 100644 index 00000000..2676fcb6 --- /dev/null +++ b/src/__tests__/Utils/history.test.ts @@ -0,0 +1,94 @@ +import { proto } from '../../../WAProto/index.js' +import { processHistoryMessage } from '../../Utils/history' + +describe('processHistoryMessage', () => { + describe('phoneNumberToLidMappings extraction', () => { + it('should extract LID-PN mappings from history sync payload', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [], + phoneNumberToLidMappings: [ + { lidJid: '11111111111111@lid', pnJid: '1234567890123@s.whatsapp.net' }, + { lidJid: '22222222222222@lid', pnJid: '9876543210987@s.whatsapp.net' } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([ + { lid: '11111111111111@lid', pn: '1234567890123@s.whatsapp.net' }, + { lid: '22222222222222@lid', pn: '9876543210987@s.whatsapp.net' } + ]) + }) + + it('should skip mappings with missing lidJid or pnJid', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.RECENT, + conversations: [], + phoneNumberToLidMappings: [ + { lidJid: undefined, pnJid: '1234567890123@s.whatsapp.net' }, + { lidJid: '11111111111111@lid', pnJid: undefined }, + { lidJid: '22222222222222@lid', pnJid: '9876543210987@s.whatsapp.net' } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([{ lid: '22222222222222@lid', pn: '9876543210987@s.whatsapp.net' }]) + }) + + it('should return empty array when no mappings exist', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([]) + }) + + it('should process mappings regardless of sync type', () => { + const syncTypes = [proto.HistorySync.HistorySyncType.PUSH_NAME, proto.HistorySync.HistorySyncType.ON_DEMAND] + + for (const syncType of syncTypes) { + const historySync: proto.IHistorySync = { + syncType, + conversations: [], + pushnames: [], + phoneNumberToLidMappings: [{ lidJid: '11111111111111@lid', pnJid: '1234567890123@s.whatsapp.net' }] + } + + const result = processHistoryMessage(historySync) + + expect(result.lidPnMappings).toEqual([{ lid: '11111111111111@lid', pn: '1234567890123@s.whatsapp.net' }]) + } + }) + }) + + describe('conversations processing', () => { + it('should extract contacts with LID and PN from conversations', () => { + const historySync: proto.IHistorySync = { + syncType: proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP, + conversations: [ + { + id: '1234567890123@s.whatsapp.net', + name: 'Test User', + lidJid: '11111111111111@lid', + pnJid: '1234567890123@s.whatsapp.net' + } + ] + } + + const result = processHistoryMessage(historySync) + + expect(result.contacts).toHaveLength(1) + expect(result.contacts[0]).toEqual({ + id: '1234567890123@s.whatsapp.net', + name: 'Test User', + lid: '11111111111111@lid', + phoneNumber: '1234567890123@s.whatsapp.net' + }) + }) + }) +}) From 8ff01b8bb32a6128e9510b4c44f49b02a34e8bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:39:41 -0300 Subject: [PATCH 08/12] fix: store LID-PN mapping from contactAction sync (#2266) * fix: store LID-PN mapping from contactAction sync * chore: improve testing of sync actions --- src/Socket/chats.ts | 8 + src/Utils/chat-utils.ts | 13 +- src/Utils/sync-action-utils.ts | 74 ++++ .../Utils/process-sync-action.test.ts | 359 ++++++++++++++++++ src/__tests__/Utils/sync-action-utils.test.ts | 186 +++++++++ 5 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 src/Utils/sync-action-utils.ts create mode 100644 src/__tests__/Utils/process-sync-action.test.ts create mode 100644 src/__tests__/Utils/sync-action-utils.test.ts diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index f7eae4ea..8e2de602 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -1185,6 +1185,14 @@ export const makeChatsSocket = (config: SocketConfig) => { }, 20_000) }) + ev.on('lid-mapping.update', async ({ lid, pn }) => { + try { + await signalRepository.lidMapping.storeLIDPNMappings([{ lid, pn }]) + } catch (error) { + logger.warn({ lid, pn, error }, 'Failed to store LID-PN mapping') + } + }) + return { ...sock, createCallLink, diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index f112777a..04cd8547 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -24,6 +24,7 @@ import { toNumber } from './generics' import type { ILogger } from './logger' import { LT_HASH_ANTI_TAMPERING } from './lt-hash' import { downloadContentFromMessage } from './messages-media' +import { emitSyncActionResults, processContactAction } from './sync-action-utils' type FetchAppStateSyncKey = (keyId: string) => Promise @@ -832,14 +833,8 @@ export const processSyncAction = ( ] }) } else if (action?.contactAction) { - ev.emit('contacts.upsert', [ - { - id: id!, - name: action.contactAction.fullName!, - lid: action.contactAction.lidJid || undefined, - phoneNumber: action.contactAction.pnJid || undefined - } - ]) + const results = processContactAction(action.contactAction, id, logger) + emitSyncActionResults(ev, results) } else if (action?.pushNameSetting) { const name = action?.pushNameSetting?.name if (name && me?.name !== name) { @@ -935,7 +930,7 @@ export const processSyncAction = ( ev.emit('contacts.upsert', [ { id: id!, - name: action.lidContactAction.fullName!, + name: action.lidContactAction.fullName || undefined, lid: id!, phoneNumber: undefined } diff --git a/src/Utils/sync-action-utils.ts b/src/Utils/sync-action-utils.ts new file mode 100644 index 00000000..d53b00fb --- /dev/null +++ b/src/Utils/sync-action-utils.ts @@ -0,0 +1,74 @@ +import { proto } from '../../WAProto/index.js' +import type { BaileysEventEmitter, BaileysEventMap, Contact } from '../Types' +import { isLidUser, isPnUser } from '../WABinary' +import type { ILogger } from './logger' + +export type ContactsUpsertResult = { + event: 'contacts.upsert' + data: Contact[] +} + +export type LidMappingUpdateResult = { + event: 'lid-mapping.update' + data: BaileysEventMap['lid-mapping.update'] +} + +export type SyncActionResult = ContactsUpsertResult | LidMappingUpdateResult + +/** + * Process contactAction and return events to emit. + * Pure function - no side effects. + */ +export const processContactAction = ( + action: proto.SyncActionValue.IContactAction, + id: string | undefined, + logger?: ILogger +): SyncActionResult[] => { + const results: SyncActionResult[] = [] + + if (!id) { + logger?.warn( + { hasFullName: !!action.fullName, hasLidJid: !!action.lidJid, hasPnJid: !!action.pnJid }, + 'contactAction sync: missing id in index' + ) + return results + } + + const lidJid = action.lidJid + const idIsPn = isPnUser(id) + // PN is in index[1], not in contactAction.pnJid which is usually null + const phoneNumber = idIsPn ? id : action.pnJid || undefined + + // Always emit contacts.upsert + results.push({ + event: 'contacts.upsert', + data: [ + { + id, + name: action.fullName || undefined, + lid: lidJid || undefined, + phoneNumber + } + ] + }) + + // Emit lid-mapping.update if we have valid LID-PN pair + if (lidJid && isLidUser(lidJid) && idIsPn) { + results.push({ + event: 'lid-mapping.update', + data: { lid: lidJid, pn: id } + }) + } + + return results +} + +export const emitSyncActionResults = (ev: BaileysEventEmitter, results: SyncActionResult[]): void => { + for (const result of results) { + if (result.event === 'contacts.upsert') { + ev.emit('contacts.upsert', result.data) + } else { + ev.emit('lid-mapping.update', result.data) + } + } +} diff --git a/src/__tests__/Utils/process-sync-action.test.ts b/src/__tests__/Utils/process-sync-action.test.ts new file mode 100644 index 00000000..2da93d30 --- /dev/null +++ b/src/__tests__/Utils/process-sync-action.test.ts @@ -0,0 +1,359 @@ +import { jest } from '@jest/globals' +import { proto } from '../../../WAProto/index.js' +import type { BaileysEventEmitter, ChatMutation, Contact } from '../../Types' +import { LabelAssociationType } from '../../Types/LabelAssociation' +import { processSyncAction } from '../../Utils/chat-utils' +import type { ILogger } from '../../Utils/logger' + +const createMockEventEmitter = () => { + const emittedEvents: Array<{ event: string; data: unknown }> = [] + const emit = jest.fn((event: string, data: unknown) => { + emittedEvents.push({ event, data }) + return true + }) + return { + emit, + emittedEvents, + on: jest.fn(), + off: jest.fn(), + removeAllListeners: jest.fn() + } as unknown as BaileysEventEmitter & { emittedEvents: typeof emittedEvents } +} + +const createMockLogger = (): ILogger => + ({ + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + child: jest.fn(function (this: ILogger) { + return this + }), + level: 'silent' + }) as unknown as ILogger + +const createSyncAction = ( + action: proto.ISyncActionValue, + index: string[] = ['type', 'id', 'msgId', '0'] +): ChatMutation => ({ + syncAction: { value: action }, + index +}) + +const mockMe: Contact = { id: 'me@s.whatsapp.net', name: 'Test User' } + +describe('processSyncAction', () => { + let ev: ReturnType + let logger: ILogger + + beforeEach(() => { + jest.clearAllMocks() + ev = createMockEventEmitter() + logger = createMockLogger() + }) + + describe('muteAction', () => { + it('emits chats.update with muteEndTime when muted', () => { + const syncAction = createSyncAction({ muteAction: { muted: true, muteEndTimestamp: 1700000000 } }, [ + 'mute', + 'chat123@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ id: 'chat123@s.whatsapp.net', muteEndTime: 1700000000 })]) + ) + }) + + it('emits null muteEndTime when unmuted', () => { + const syncAction = createSyncAction({ muteAction: { muted: false, muteEndTimestamp: 0 } }, [ + 'mute', + 'chat123@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ muteEndTime: null })]) + ) + }) + }) + + describe('archiveChatAction', () => { + it('emits chats.update with archived true/false', () => { + const archived = createSyncAction({ archiveChatAction: { archived: true } }, ['archive', 'chat@s.whatsapp.net']) + processSyncAction(archived, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ archived: true })]) + ) + }) + + it('handles type fallback without archiveChatAction', () => { + const syncAction = createSyncAction({}, ['archive', 'chat@s.whatsapp.net']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ archived: true })]) + ) + }) + }) + + describe('markChatAsReadAction', () => { + it('emits unreadCount 0 when read is true', () => { + const read = createSyncAction({ markChatAsReadAction: { read: true } }, ['markRead', 'chat@s.whatsapp.net']) + processSyncAction(read, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ unreadCount: 0 })]) + ) + }) + + it('emits unreadCount -1 when read is false', () => { + const unread = createSyncAction({ markChatAsReadAction: { read: false } }, ['markRead', 'chat@s.whatsapp.net']) + processSyncAction(unread, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ unreadCount: -1 })]) + ) + }) + + it('emits null unreadCount during initial sync when already read', () => { + const syncAction = createSyncAction({ markChatAsReadAction: { read: true } }, ['markRead', 'chat@s.whatsapp.net']) + processSyncAction(syncAction, ev, mockMe, { accountSettings: { unarchiveChats: false } }, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ unreadCount: null })]) + ) + }) + }) + + describe('deleteMessageForMeAction', () => { + it('emits messages.delete with correct key', () => { + const syncAction = createSyncAction({ deleteMessageForMeAction: { deleteMedia: false } }, [ + 'deleteMessageForMe', + 'chat@s.whatsapp.net', + 'msg456', + '1' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('messages.delete', { + keys: [{ remoteJid: 'chat@s.whatsapp.net', id: 'msg456', fromMe: true }] + }) + }) + }) + + describe('contactAction', () => { + it('emits contacts.upsert and lid-mapping.update for PN user with LID', () => { + const syncAction = createSyncAction({ contactAction: { fullName: 'John', lidJid: '123@lid', pnJid: null } }, [ + 'contact', + '5511999@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('contacts.upsert', [ + { + id: '5511999@s.whatsapp.net', + name: 'John', + lid: '123@lid', + phoneNumber: '5511999@s.whatsapp.net' + } + ]) + expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', { + lid: '123@lid', + pn: '5511999@s.whatsapp.net' + }) + }) + + it('does not emit events when id is missing', () => { + const syncAction = createSyncAction({ contactAction: { fullName: 'John', lidJid: '123@lid', pnJid: null } }, [ + 'contact', + '' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emittedEvents.filter(e => e.event === 'contacts.upsert')).toHaveLength(0) + }) + }) + + describe('pushNameSetting', () => { + it('emits creds.update when name differs', () => { + const syncAction = createSyncAction({ pushNameSetting: { name: 'New' } }, ['pushName']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('creds.update', { me: { ...mockMe, name: 'New' } }) + }) + + it('does not emit when name is same or empty', () => { + const same = createSyncAction({ pushNameSetting: { name: 'Test User' } }, ['pushName']) + processSyncAction(same, ev, mockMe, undefined, logger) + expect(ev.emit).not.toHaveBeenCalled() + }) + }) + + describe('pinAction', () => { + it('emits chats.update with pinned timestamp or null', () => { + const syncAction: ChatMutation = { + syncAction: { value: { pinAction: { pinned: true }, timestamp: 1700000000 } }, + index: ['pin', 'chat@s.whatsapp.net'] + } + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith( + 'chats.update', + expect.arrayContaining([expect.objectContaining({ pinned: 1700000000 })]) + ) + }) + }) + + describe('starAction', () => { + it('emits messages.update with starred value', () => { + const syncAction = createSyncAction({ starAction: { starred: true } }, [ + 'star', + 'chat@s.whatsapp.net', + 'msg', + '1' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('messages.update', [ + { + key: { remoteJid: 'chat@s.whatsapp.net', id: 'msg', fromMe: true }, + update: { starred: true } + } + ]) + }) + }) + + describe('deleteChatAction', () => { + it('emits chats.delete when not initial sync', () => { + const syncAction = createSyncAction({ deleteChatAction: { messageRange: null } }, [ + 'deleteChat', + 'chat@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('chats.delete', ['chat@s.whatsapp.net']) + }) + + it('does NOT emit during initial sync', () => { + const syncAction = createSyncAction({ deleteChatAction: { messageRange: null } }, [ + 'deleteChat', + 'chat@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, { accountSettings: { unarchiveChats: false } }, logger) + expect(ev.emit).not.toHaveBeenCalled() + }) + }) + + describe('labelEditAction', () => { + it('emits labels.edit', () => { + const syncAction = createSyncAction( + { labelEditAction: { name: 'Important', color: 1, deleted: false, predefinedId: 5 } }, + ['label', 'label123'] + ) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('labels.edit', { + id: 'label123', + name: 'Important', + color: 1, + deleted: false, + predefinedId: '5' + }) + }) + }) + + describe('labelAssociationAction', () => { + it('emits labels.association for chat label', () => { + const syncAction = createSyncAction({ labelAssociationAction: { labeled: true } }, [ + LabelAssociationType.Chat, + 'label123', + 'chat@s.whatsapp.net' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('labels.association', { + type: 'add', + association: { type: LabelAssociationType.Chat, chatId: 'chat@s.whatsapp.net', labelId: 'label123' } + }) + }) + + it('emits labels.association for message label', () => { + const syncAction = createSyncAction({ labelAssociationAction: { labeled: true } }, [ + LabelAssociationType.Message, + 'label123', + 'chat@s.whatsapp.net', + 'msg789' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('labels.association', { + type: 'add', + association: { + type: LabelAssociationType.Message, + chatId: 'chat@s.whatsapp.net', + messageId: 'msg789', + labelId: 'label123' + } + }) + }) + }) + + describe('pnForLidChatAction', () => { + it('emits lid-mapping.update when pnJid is present', () => { + const syncAction = createSyncAction({ pnForLidChatAction: { pnJid: '5511999@s.whatsapp.net' } }, [ + 'pnForLid', + '123@lid' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', { lid: '123@lid', pn: '5511999@s.whatsapp.net' }) + }) + + it('does not emit when pnJid is missing', () => { + const syncAction = createSyncAction({ pnForLidChatAction: { pnJid: '' } }, ['pnForLid', '123@lid']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).not.toHaveBeenCalled() + }) + }) + + describe('lockChatAction', () => { + it('emits chats.lock', () => { + const syncAction = createSyncAction({ lockChatAction: { locked: true } }, ['lockChat', 'chat@s.whatsapp.net']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('chats.lock', { id: 'chat@s.whatsapp.net', locked: true }) + }) + }) + + describe('lidContactAction', () => { + it('emits contacts.upsert with LID contact', () => { + const syncAction = createSyncAction({ lidContactAction: { fullName: 'LID Contact' } }, ['lidContact', '123@lid']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('contacts.upsert', [ + { + id: '123@lid', + name: 'LID Contact', + lid: '123@lid', + phoneNumber: undefined + } + ]) + }) + }) + + describe('settings actions', () => { + it('localeSetting emits settings.update', () => { + const syncAction = createSyncAction({ localeSetting: { locale: 'en-US' } }, ['locale']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('settings.update', { setting: 'locale', value: 'en-US' }) + }) + + it('unarchiveChatsSetting emits creds.update', () => { + const syncAction = createSyncAction({ unarchiveChatsSetting: { unarchiveChats: true } }, ['unarchiveChats']) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(ev.emit).toHaveBeenCalledWith('creds.update', { accountSettings: { unarchiveChats: true } }) + }) + }) + + describe('unprocessable actions', () => { + it('logs debug for unknown action', () => { + const syncAction = createSyncAction({ unknownAction: {} } as unknown as proto.ISyncActionValue, [ + 'unknown', + 'id123' + ]) + processSyncAction(syncAction, ev, mockMe, undefined, logger) + expect(logger.debug).toHaveBeenCalledWith({ syncAction, id: 'id123' }, 'unprocessable update') + expect(ev.emit).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts new file mode 100644 index 00000000..cd608c3d --- /dev/null +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -0,0 +1,186 @@ +import { jest } from '@jest/globals' +import type { BaileysEventMap, Contact } from '../../Types' +import type { ILogger } from '../../Utils/logger' +import { processContactAction } from '../../Utils/sync-action-utils' + +type LidMapping = BaileysEventMap['lid-mapping.update'] + +describe('processContactAction', () => { + const mockLogger: ILogger = { + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + child: jest.fn(() => mockLogger), + level: 'silent' + } as unknown as ILogger + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('contacts.upsert', () => { + it('emits with phoneNumber from id when id is PN user', () => { + const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + expect(results).toContainEqual({ + event: 'contacts.upsert', + data: [ + { + id: '5511999999999@s.whatsapp.net', + name: 'John Doe', + lid: '123456789@lid', + phoneNumber: '5511999999999@s.whatsapp.net' + } + ] + }) + }) + + it('uses pnJid as phoneNumber fallback when id is LID user', () => { + const action = { fullName: 'John Doe', lidJid: null, pnJid: '5511888888888@s.whatsapp.net' } + const id = '123456789@lid' + + const results = processContactAction(action, id) + + expect(results).toContainEqual({ + event: 'contacts.upsert', + data: [ + { + id: '123456789@lid', + name: 'John Doe', + lid: undefined, + phoneNumber: '5511888888888@s.whatsapp.net' + } + ] + }) + }) + + it('handles undefined fullName', () => { + const action = { fullName: undefined, lidJid: '123456789@lid', pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + const contactData = results.find(r => r.event === 'contacts.upsert')!.data + expect(contactData[0]!.name).toBeUndefined() + }) + }) + + describe('lid-mapping.update', () => { + it('emits when LID and PN are valid', () => { + const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + expect(results).toContainEqual({ + event: 'lid-mapping.update', + data: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' } + }) + }) + + it('handles LID with device ID suffix', () => { + const action = { fullName: 'Contact', lidJid: '173233882013816:99@lid', pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + expect(results).toContainEqual({ + event: 'lid-mapping.update', + data: { lid: '173233882013816:99@lid', pn: '5511999999999@s.whatsapp.net' } + }) + }) + + it('does NOT emit when lidJid is missing', () => { + const action = { fullName: 'John Doe', lidJid: null, pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined() + }) + + it('does NOT emit when id is LID user (not PN)', () => { + const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null } + const id = '987654321@lid' + + const results = processContactAction(action, id) + + expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined() + }) + + it('does NOT emit when lidJid is invalid format', () => { + const action = { fullName: 'John Doe', lidJid: 'invalid-lid-format', pnJid: null } + const id = '5511999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined() + }) + + it('does NOT emit for group JIDs', () => { + const action = { fullName: 'Group', lidJid: '123456789@lid', pnJid: null } + const id = '123456789012345678@g.us' + + const results = processContactAction(action, id) + + expect(results.find(r => r.event === 'lid-mapping.update')).toBeUndefined() + }) + }) + + describe('missing id', () => { + it('returns empty array and logs warning when id is undefined', () => { + const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null } + + const results = processContactAction(action, undefined, mockLogger) + + expect(results).toEqual([]) + expect(mockLogger.warn).toHaveBeenCalledWith( + { hasFullName: true, hasLidJid: true, hasPnJid: false }, + 'contactAction sync: missing id in index' + ) + }) + + it('returns empty array when id is empty string', () => { + const action = { fullName: 'John Doe', lidJid: '123456789@lid', pnJid: null } + + const results = processContactAction(action, '', mockLogger) + + expect(results).toEqual([]) + }) + }) + + describe('PN extraction from index[1] (pnJid is always null)', () => { + it('extracts PN from id when pnJid is null', () => { + // In real WhatsApp data, pnJid is ALWAYS null - PN comes from index[1] + const action = { fullName: 'Test Contact', lidJid: '111222333@lid', pnJid: null } + const id = '5599887766@s.whatsapp.net' + + const results = processContactAction(action, id) + + const contactData = results.find(r => r.event === 'contacts.upsert')!.data + expect(contactData[0]!.phoneNumber).toBe('5599887766@s.whatsapp.net') + expect(contactData[0]!.lid).toBe('111222333@lid') + + const mapping = results.find(r => r.event === 'lid-mapping.update')!.data + expect(mapping).toEqual({ lid: '111222333@lid', pn: '5599887766@s.whatsapp.net' }) + }) + + it('prefers id over pnJid when id is PN user', () => { + const action = { fullName: 'Test', lidJid: '111222333@lid', pnJid: '1111111111@s.whatsapp.net' } + const id = '9999999999@s.whatsapp.net' + + const results = processContactAction(action, id) + + const contactData = results.find(r => r.event === 'contacts.upsert')!.data + expect(contactData[0]!.phoneNumber).toBe('9999999999@s.whatsapp.net') + + const mapping = results.find(r => r.event === 'lid-mapping.update')!.data + expect(mapping.pn).toBe('9999999999@s.whatsapp.net') + }) + }) +}) From d36d9c194c64e5d84976b6e97536a7870935c389 Mon Sep 17 00:00:00 2001 From: David ??? <86541514+DavidModzz@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:41:33 -0300 Subject: [PATCH 09/12] Add groupStatusMessage checks in message handling (#2258) --- src/Utils/messages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 60c64a8a..24f07d74 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -782,7 +782,9 @@ export const normalizeMessageContent = (content: WAMessageContent | null | undef message?.documentWithCaptionMessage || message?.viewOnceMessageV2 || message?.viewOnceMessageV2Extension || - message?.editedMessage + message?.editedMessage || + message?.groupStatusMessage || + message?.groupStatusMessageV2 ) } } From 90e8ba82f4fd5556e9109707cc7a25a6cf57c98e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 20 Jan 2026 12:43:27 +0200 Subject: [PATCH 10/12] Cache the children after a getBinaryNodeChild/ren call to avoid traversing arrays (#2093) * generic-utils: cache the get * generic-utils: increased type safety * chore: lint --- src/WABinary/generic-utils.ts | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/WABinary/generic-utils.ts b/src/WABinary/generic-utils.ts index 0085f661..864394db 100644 --- a/src/WABinary/generic-utils.ts +++ b/src/WABinary/generic-utils.ts @@ -4,12 +4,32 @@ import { type BinaryNode } from './types' // some extra useful utilities +const indexCache = new WeakMap>() + export const getBinaryNodeChildren = (node: BinaryNode | undefined, childTag: string) => { - if (Array.isArray(node?.content)) { - return node.content.filter(item => item.tag === childTag) + if (!node || !Array.isArray(node.content)) return [] + + let index = indexCache.get(node) + + // Build the index once per node + if (!index) { + index = new Map() + + for (const child of node.content) { + let arr = index.get(child.tag) + if (!arr) index.set(child.tag, (arr = [])) + arr.push(child) + } + + indexCache.set(node, index) } - return [] + // Return first matching child + return index.get(childTag) || [] +} + +export const getBinaryNodeChild = (node: BinaryNode | undefined, childTag: string) => { + return getBinaryNodeChildren(node, childTag)[0] } export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => { @@ -20,12 +40,6 @@ export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => { return [] } -export const getBinaryNodeChild = (node: BinaryNode | undefined, childTag: string) => { - if (Array.isArray(node?.content)) { - return node?.content.find(item => item.tag === childTag) - } -} - export const getBinaryNodeChildBuffer = (node: BinaryNode | undefined, childTag: string) => { const child = getBinaryNodeChild(node, childTag)?.content if (Buffer.isBuffer(child) || child instanceof Uint8Array) { From a1d69f72c95b94eb6ab5e1ce0b341bdb6bf8cf60 Mon Sep 17 00:00:00 2001 From: Enzo Nascimento <143226080+devenzonascimento@users.noreply.github.com> Date: Tue, 20 Jan 2026 07:46:03 -0300 Subject: [PATCH 11/12] fix(utils.normalizeMessageContent): add associatedChildMessage as one of the options to normalize (#1874) Co-authored-by: Rajeh Taher --- src/Utils/messages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 24f07d74..c310e834 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -783,6 +783,7 @@ export const normalizeMessageContent = (content: WAMessageContent | null | undef message?.viewOnceMessageV2 || message?.viewOnceMessageV2Extension || message?.editedMessage || + message?.associatedChildMessage || message?.groupStatusMessage || message?.groupStatusMessageV2 ) From b6b708ddfea18a89784fd6cfe24fceca98eb7178 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 20 Jan 2026 12:47:05 +0200 Subject: [PATCH 12/12] chore(tests): lint --- src/__tests__/Utils/sync-action-utils.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts index cd608c3d..5c19d29e 100644 --- a/src/__tests__/Utils/sync-action-utils.test.ts +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -1,9 +1,7 @@ import { jest } from '@jest/globals' -import type { BaileysEventMap, Contact } from '../../Types' import type { ILogger } from '../../Utils/logger' import { processContactAction } from '../../Utils/sync-action-utils' -type LidMapping = BaileysEventMap['lid-mapping.update'] describe('processContactAction', () => { const mockLogger: ILogger = {