Merge branch 'WhiskeySockets:master' into master
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"version":[2,3000,1027934701]}
|
{"version":[2,3000,1032141294]}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AuthenticationState, SocketConfig, WAVersion } from '../Types'
|
|||||||
import { Browsers } from '../Utils/browser-utils'
|
import { Browsers } from '../Utils/browser-utils'
|
||||||
import logger from '../Utils/logger'
|
import logger from '../Utils/logger'
|
||||||
|
|
||||||
const version = [2, 3000, 1029027441]
|
const version = [2, 3000, 1032141294]
|
||||||
|
|
||||||
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||||
|
|
||||||
|
|||||||
@@ -1185,6 +1185,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
}, 20_000)
|
}, 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 {
|
return {
|
||||||
...sock,
|
...sock,
|
||||||
createCallLink,
|
createCallLink,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
MISSING_KEYS_ERROR_TEXT,
|
MISSING_KEYS_ERROR_TEXT,
|
||||||
NACK_REASONS,
|
NACK_REASONS,
|
||||||
NO_MESSAGE_FOUND_ERROR_TEXT,
|
NO_MESSAGE_FOUND_ERROR_TEXT,
|
||||||
|
toNumber,
|
||||||
unixTimestampSeconds,
|
unixTimestampSeconds,
|
||||||
xmppPreKey,
|
xmppPreKey,
|
||||||
xmppSignedPreKey
|
xmppSignedPreKey
|
||||||
@@ -1097,7 +1098,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
'messages.update',
|
'messages.update',
|
||||||
ids.map(id => ({
|
ids.map(id => ({
|
||||||
key: { ...key, id },
|
key: { ...key, id },
|
||||||
update: { status }
|
update: { status, messageTimestamp: toNumber(+(attrs.t ?? 0)) }
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import type { Boom } from '@hapi/boom'
|
import type { Boom } from '@hapi/boom'
|
||||||
import { proto } from '../../WAProto/index.js'
|
import { proto } from '../../WAProto/index.js'
|
||||||
import type { AuthenticationCreds } from './Auth'
|
import type { AuthenticationCreds, LIDMapping } from './Auth'
|
||||||
import type { WACallEvent } from './Call'
|
import type { WACallEvent } from './Call'
|
||||||
import type { Chat, ChatUpdate, PresenceData } from './Chat'
|
import type { Chat, ChatUpdate, PresenceData } from './Chat'
|
||||||
import type { Contact } from './Contact'
|
import type { Contact } from './Contact'
|
||||||
@@ -36,7 +36,7 @@ export type BaileysEventMap = {
|
|||||||
'chats.upsert': Chat[]
|
'chats.upsert': Chat[]
|
||||||
/** update the given chats */
|
/** update the given chats */
|
||||||
'chats.update': ChatUpdate[]
|
'chats.update': ChatUpdate[]
|
||||||
'lid-mapping.update': { lid: string; pn: string }
|
'lid-mapping.update': LIDMapping
|
||||||
/** delete chats with given ID */
|
/** delete chats with given ID */
|
||||||
'chats.delete': string[]
|
'chats.delete': string[]
|
||||||
/** presence of contact in a chat updated */
|
/** presence of contact in a chat updated */
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { toNumber } from './generics'
|
|||||||
import type { ILogger } from './logger'
|
import type { ILogger } from './logger'
|
||||||
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
|
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
|
||||||
import { downloadContentFromMessage } from './messages-media'
|
import { downloadContentFromMessage } from './messages-media'
|
||||||
|
import { emitSyncActionResults, processContactAction } from './sync-action-utils'
|
||||||
|
|
||||||
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.Message.IAppStateSyncKeyData | null | undefined>
|
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.Message.IAppStateSyncKeyData | null | undefined>
|
||||||
|
|
||||||
@@ -832,14 +833,8 @@ export const processSyncAction = (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
} else if (action?.contactAction) {
|
} else if (action?.contactAction) {
|
||||||
ev.emit('contacts.upsert', [
|
const results = processContactAction(action.contactAction, id, logger)
|
||||||
{
|
emitSyncActionResults(ev, results)
|
||||||
id: id!,
|
|
||||||
name: action.contactAction.fullName!,
|
|
||||||
lid: action.contactAction.lidJid || undefined,
|
|
||||||
phoneNumber: action.contactAction.pnJid || undefined
|
|
||||||
}
|
|
||||||
])
|
|
||||||
} else if (action?.pushNameSetting) {
|
} else if (action?.pushNameSetting) {
|
||||||
const name = action?.pushNameSetting?.name
|
const name = action?.pushNameSetting?.name
|
||||||
if (name && me?.name !== name) {
|
if (name && me?.name !== name) {
|
||||||
@@ -935,7 +930,7 @@ export const processSyncAction = (
|
|||||||
ev.emit('contacts.upsert', [
|
ev.emit('contacts.upsert', [
|
||||||
{
|
{
|
||||||
id: id!,
|
id: id!,
|
||||||
name: action.lidContactAction.fullName!,
|
name: action.lidContactAction.fullName || undefined,
|
||||||
lid: id!,
|
lid: id!,
|
||||||
phoneNumber: undefined
|
phoneNumber: undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Boom } from '@hapi/boom'
|
import { Boom } from '@hapi/boom'
|
||||||
import { createHash, randomBytes } from 'crypto'
|
import { createHash, randomBytes } from 'crypto'
|
||||||
import { proto } from '../../WAProto/index.js'
|
import { proto } from '../../WAProto/index.js'
|
||||||
const baileysVersion = [2, 3000, 1027934701]
|
const baileysVersion = [2, 3000, 1032141294]
|
||||||
import type {
|
import type {
|
||||||
BaileysEventEmitter,
|
BaileysEventEmitter,
|
||||||
BaileysEventMap,
|
BaileysEventMap,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { inflate } from 'zlib'
|
import { inflate } from 'zlib'
|
||||||
import { proto } from '../../WAProto/index.js'
|
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 { WAMessageStubType } from '../Types'
|
||||||
import { toNumber } from './generics'
|
import { toNumber } from './generics'
|
||||||
import { normalizeMessageContent } from './messages'
|
import { normalizeMessageContent } from './messages'
|
||||||
@@ -29,6 +29,13 @@ export const processHistoryMessage = (item: proto.IHistorySync) => {
|
|||||||
const messages: WAMessage[] = []
|
const messages: WAMessage[] = []
|
||||||
const contacts: Contact[] = []
|
const contacts: Contact[] = []
|
||||||
const chats: Chat[] = []
|
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) {
|
switch (item.syncType) {
|
||||||
case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP:
|
case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP:
|
||||||
@@ -87,6 +94,7 @@ export const processHistoryMessage = (item: proto.IHistorySync) => {
|
|||||||
chats,
|
chats,
|
||||||
contacts,
|
contacts,
|
||||||
messages,
|
messages,
|
||||||
|
lidPnMappings,
|
||||||
syncType: item.syncType,
|
syncType: item.syncType,
|
||||||
progress: item.progress
|
progress: item.progress
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -782,7 +782,10 @@ export const normalizeMessageContent = (content: WAMessageContent | null | undef
|
|||||||
message?.documentWithCaptionMessage ||
|
message?.documentWithCaptionMessage ||
|
||||||
message?.viewOnceMessageV2 ||
|
message?.viewOnceMessageV2 ||
|
||||||
message?.viewOnceMessageV2Extension ||
|
message?.viewOnceMessageV2Extension ||
|
||||||
message?.editedMessage
|
message?.editedMessage ||
|
||||||
|
message?.associatedChildMessage ||
|
||||||
|
message?.groupStatusMessage ||
|
||||||
|
message?.groupStatusMessageV2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-83
@@ -7,13 +7,48 @@ import { decodeBinaryNode } from '../WABinary'
|
|||||||
import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto'
|
import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto'
|
||||||
import type { ILogger } from './logger'
|
import type { ILogger } from './logger'
|
||||||
|
|
||||||
const generateIV = (counter: number) => {
|
const IV_LENGTH = 12
|
||||||
const iv = new ArrayBuffer(12)
|
|
||||||
new DataView(iv).setUint32(8, counter)
|
|
||||||
|
|
||||||
|
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)
|
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 = ({
|
export const makeNoiseHandler = ({
|
||||||
keyPair: { private: privateKey, public: publicKey },
|
keyPair: { private: privateKey, public: publicKey },
|
||||||
NOISE_HEADER,
|
NOISE_HEADER,
|
||||||
@@ -27,40 +62,63 @@ export const makeNoiseHandler = ({
|
|||||||
}) => {
|
}) => {
|
||||||
logger = logger.child({ class: 'ns' })
|
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) => {
|
const authenticate = (data: Uint8Array) => {
|
||||||
if (!isFinished) {
|
if (!transport) {
|
||||||
hash = sha256(Buffer.concat([hash, data]))
|
hash = sha256(Buffer.concat([hash, data]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypt = (plaintext: Uint8Array) => {
|
const encrypt = (plaintext: Uint8Array): Uint8Array => {
|
||||||
const result = aesEncryptGCM(plaintext, encKey, generateIV(writeCounter), hash)
|
if (transport) {
|
||||||
|
return transport.encrypt(plaintext)
|
||||||
writeCounter += 1
|
}
|
||||||
|
|
||||||
|
const result = aesEncryptGCM(plaintext, encKey, generateIV(counter++), hash)
|
||||||
authenticate(result)
|
authenticate(result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const decrypt = (ciphertext: Uint8Array) => {
|
const decrypt = (ciphertext: Uint8Array): Uint8Array => {
|
||||||
// before the handshake is finished, we use the same counter
|
if (transport) {
|
||||||
// after handshake, the counters are different
|
return transport.decrypt(ciphertext)
|
||||||
const iv = generateIV(isFinished ? readCounter : writeCounter)
|
|
||||||
const result = aesDecryptGCM(ciphertext, decKey, iv, hash)
|
|
||||||
|
|
||||||
if (isFinished) {
|
|
||||||
readCounter += 1
|
|
||||||
} else {
|
|
||||||
writeCounter += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = aesDecryptGCM(ciphertext, decKey, generateIV(counter++), hash)
|
||||||
authenticate(ciphertext)
|
authenticate(ciphertext)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const localHKDF = async (data: Uint8Array) => {
|
const localHKDF = async (data: Uint8Array) => {
|
||||||
const key = await hkdf(Buffer.from(data), 64, { salt, info: '' })
|
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) => {
|
const mixIntoKey = async (data: Uint8Array) => {
|
||||||
@@ -68,31 +126,49 @@ export const makeNoiseHandler = ({
|
|||||||
salt = write!
|
salt = write!
|
||||||
encKey = read!
|
encKey = read!
|
||||||
decKey = read!
|
decKey = read!
|
||||||
readCounter = 0
|
counter = 0
|
||||||
writeCounter = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishInit = async () => {
|
const finishInit = async () => {
|
||||||
|
isWaitingForTransport = true
|
||||||
const [write, read] = await localHKDF(new Uint8Array(0))
|
const [write, read] = await localHKDF(new Uint8Array(0))
|
||||||
encKey = write!
|
transport = new TransportState(write!, read!)
|
||||||
decKey = read!
|
isWaitingForTransport = false
|
||||||
hash = Buffer.from([])
|
|
||||||
readCounter = 0
|
logger.trace('Noise handler transitioned to Transport state')
|
||||||
writeCounter = 0
|
|
||||||
isFinished = true
|
if (pendingOnFrame) {
|
||||||
|
logger.trace({ length: inBytes.length }, 'Flushing buffered frames after transport ready')
|
||||||
|
await processData(pendingOnFrame)
|
||||||
|
pendingOnFrame = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = Buffer.from(NOISE_MODE)
|
const processData = async (onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
||||||
let hash = data.byteLength === 32 ? data : sha256(data)
|
let size: number | undefined
|
||||||
let salt = hash
|
|
||||||
let encKey = hash
|
|
||||||
let decKey = hash
|
|
||||||
let readCounter = 0
|
|
||||||
let writeCounter = 0
|
|
||||||
let isFinished = false
|
|
||||||
let sentIntro = false
|
|
||||||
|
|
||||||
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(NOISE_HEADER)
|
||||||
authenticate(publicKey)
|
authenticate(publicKey)
|
||||||
@@ -152,67 +228,41 @@ export const makeNoiseHandler = ({
|
|||||||
return keyEnc
|
return keyEnc
|
||||||
},
|
},
|
||||||
encodeFrame: (data: Buffer | Uint8Array) => {
|
encodeFrame: (data: Buffer | Uint8Array) => {
|
||||||
if (isFinished) {
|
if (transport) {
|
||||||
data = encrypt(data)
|
data = transport.encrypt(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
let header: Buffer
|
const dataLen = data.byteLength
|
||||||
|
const introSize = sentIntro ? 0 : introHeader.length
|
||||||
if (routingInfo) {
|
const frame = Buffer.allocUnsafe(introSize + 3 + dataLen)
|
||||||
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)
|
|
||||||
|
|
||||||
if (!sentIntro) {
|
if (!sentIntro) {
|
||||||
frame.set(header)
|
frame.set(introHeader)
|
||||||
sentIntro = true
|
sentIntro = true
|
||||||
}
|
}
|
||||||
|
|
||||||
frame.writeUInt8(data.byteLength >> 16, introSize)
|
frame[introSize] = (dataLen >>> 16) & 0xff
|
||||||
frame.writeUInt16BE(65535 & data.byteLength, introSize + 1)
|
frame[introSize + 1] = (dataLen >>> 8) & 0xff
|
||||||
|
frame[introSize + 2] = dataLen & 0xff
|
||||||
|
|
||||||
frame.set(data, introSize + 3)
|
frame.set(data, introSize + 3)
|
||||||
|
|
||||||
return frame
|
return frame
|
||||||
},
|
},
|
||||||
decodeFrame: async (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
decodeFrame: async (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
||||||
// the binary protocol uses its own framing mechanism
|
if (isWaitingForTransport) {
|
||||||
// on top of the WS frames
|
inBytes = Buffer.concat([inBytes, newData])
|
||||||
// so we get this data and separate out the frames
|
pendingOnFrame = onFrame
|
||||||
const getBytesSize = () => {
|
return
|
||||||
if (inBytes.length >= 3) {
|
|
||||||
return (inBytes.readUInt8() << 16) | inBytes.readUInt16BE(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inBytes = Buffer.concat([inBytes, newData])
|
if (inBytes.length === 0) {
|
||||||
|
inBytes = Buffer.from(newData)
|
||||||
logger.trace(`recv ${newData.length} bytes, total recv ${inBytes.length} bytes`)
|
} else {
|
||||||
|
inBytes = Buffer.concat([inBytes, newData])
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await processData(onFrame)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,16 @@ const processMessage = async (
|
|||||||
|
|
||||||
const data = await downloadAndProcessHistorySyncNotification(histNotification, options)
|
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', {
|
ev.emit('messaging-history.set', {
|
||||||
...data,
|
...data,
|
||||||
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
|
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,32 @@ import { type BinaryNode } from './types'
|
|||||||
|
|
||||||
// some extra useful utilities
|
// some extra useful utilities
|
||||||
|
|
||||||
|
const indexCache = new WeakMap<BinaryNode, Map<string, BinaryNode[]>>()
|
||||||
|
|
||||||
export const getBinaryNodeChildren = (node: BinaryNode | undefined, childTag: string) => {
|
export const getBinaryNodeChildren = (node: BinaryNode | undefined, childTag: string) => {
|
||||||
if (Array.isArray(node?.content)) {
|
if (!node || !Array.isArray(node.content)) return []
|
||||||
return node.content.filter(item => item.tag === childTag)
|
|
||||||
|
let index = indexCache.get(node)
|
||||||
|
|
||||||
|
// Build the index once per node
|
||||||
|
if (!index) {
|
||||||
|
index = new Map<string, BinaryNode[]>()
|
||||||
|
|
||||||
|
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) => {
|
export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => {
|
||||||
@@ -20,12 +40,6 @@ export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => {
|
|||||||
return []
|
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) => {
|
export const getBinaryNodeChildBuffer = (node: BinaryNode | undefined, childTag: string) => {
|
||||||
const child = getBinaryNodeChild(node, childTag)?.content
|
const child = getBinaryNodeChild(node, childTag)?.content
|
||||||
if (Buffer.isBuffer(child) || child instanceof Uint8Array) {
|
if (Buffer.isBuffer(child) || child instanceof Uint8Array) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof createMockEventEmitter>
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { jest } from '@jest/globals'
|
||||||
|
import type { ILogger } from '../../Utils/logger'
|
||||||
|
import { processContactAction } from '../../Utils/sync-action-utils'
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user