diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 9b75ecf6..9fd76b1c 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -10,10 +10,8 @@ import type { ILogger } from '../Utils/logger' import { metrics } from '../Utils/prometheus-metrics.js' import { CircuitBreaker } from '../Utils/circuit-breaker.js' import { - isHostedLidUser, - isHostedPnUser, - isLidUser, - isPnUser, + isAnyLidUser, + isAnyPnUser, jidDecode, transferDevice, WAJIDDomains @@ -477,10 +475,10 @@ export function makeLibSignalRepository( toJid: string ): Promise<{ migrated: number; skipped: number; total: number }> { // TODO: use usync to handle this entire mess - if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 } + if (!fromJid || !isAnyLidUser(toJid)) return { migrated: 0, skipped: 0, total: 0 } // Only support PN to LID migration - if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) { + if (!isAnyPnUser(fromJid)) { return { migrated: 0, skipped: 0, total: 1 } } diff --git a/src/Signal/lid-mapping.ts b/src/Signal/lid-mapping.ts index dba3e07c..170e8cce 100644 --- a/src/Signal/lid-mapping.ts +++ b/src/Signal/lid-mapping.ts @@ -1,7 +1,7 @@ import { LRUCache } from 'lru-cache' import type { LIDMapping, SignalKeyStoreWithTransaction } from '../Types' import type { ILogger } from '../Utils/logger' -import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser, jidDecode, jidNormalizedUser, WAJIDDomains } from '../WABinary' +import { isAnyLidUser, isAnyPnUser, isHostedPnUser, jidDecode, jidNormalizedUser, WAJIDDomains } from '../WABinary' // ============================================================================ // CONFIGURATION @@ -325,8 +325,9 @@ export class LIDMappingStore { const result = { stored: 0, skipped: 0, errors: 0 } - // Validate and prepare mappings - const validPairs: { [pnUser: string]: string } = {} + // Phase 1: Validate and collect cache misses + const cacheMissPnUsers: string[] = [] + const pendingValidation = new Map() for (const { lid, pn } of pairs) { if (!this.isValidMapping(lid, pn)) { @@ -348,59 +349,90 @@ export class LIDMappingStore { const lidUser = lidDecoded.user // Check cache first - let existingLidUser = this.mappingCache.get(`pn:${pnUser}`) + const existingLidUser = this.mappingCache.get(`pn:${pnUser}`) - if (!existingLidUser) { - // Cache miss - check database - this.stats.cacheMisses++ - try { - const stored = await this.retryOperation( - () => this.keys.get('lid-mapping', [pnUser]), - 'get-mapping' - ) - existingLidUser = stored[pnUser] - - if (existingLidUser) { - this.stats.dbHits++ - // Update cache with database value - this.mappingCache.set(`pn:${pnUser}`, existingLidUser) - this.mappingCache.set(`lid:${existingLidUser}`, pnUser) - } else { - this.stats.dbMisses++ + if (existingLidUser !== undefined) { + // Cache hit + this.stats.cacheHits++ + if (existingLidUser === lidUser) { + if (this.config.debugLogging) { + this.logger.debug({ pnUser, lidUser }, 'LID mapping already exists, skipping') } - } catch (error) { - this.logger.error({ error, pnUser }, 'Failed to check existing mapping') - result.errors++ - continue + result.skipped++ + } else { + // Different mapping - will be stored + pendingValidation.set(pnUser, { pnUser, lidUser }) } } else { - this.stats.cacheHits++ + // Cache miss - queue for batch DB fetch + this.stats.cacheMisses++ + cacheMissPnUsers.push(pnUser) + pendingValidation.set(pnUser, { pnUser, lidUser }) } - - if (existingLidUser === lidUser) { - if (this.config.debugLogging) { - this.logger.debug({ pnUser, lidUser }, 'LID mapping already exists, skipping') - } - result.skipped++ - continue - } - - validPairs[pnUser] = lidUser } - if (Object.keys(validPairs).length === 0) { + // Phase 2: Batch fetch all cache misses from DB + if (cacheMissPnUsers.length > 0) { + const batches = this.chunkArray(cacheMissPnUsers, this.config.batchSize) + + for (const batch of batches) { + try { + const stored = await this.retryOperation( + () => this.keys.get('lid-mapping', batch), + 'batch-get-mappings' + ) + + // Update cache and validate against DB + for (const pnUser of batch) { + const existingLidUser = stored[pnUser] + + if (existingLidUser) { + this.stats.dbHits++ + // Update cache with database value + this.mappingCache.set(`pn:${pnUser}`, existingLidUser) + this.mappingCache.set(`lid:${existingLidUser}`, pnUser) + + // Check if this mapping should be skipped + const pending = pendingValidation.get(pnUser) + if (pending && existingLidUser === pending.lidUser) { + if (this.config.debugLogging) { + this.logger.debug( + { pnUser, lidUser: pending.lidUser }, + 'LID mapping already exists in DB, skipping' + ) + } + result.skipped++ + pendingValidation.delete(pnUser) + } + } else { + this.stats.dbMisses++ + } + } + } catch (error) { + this.logger.error({ error, batchSize: batch.length }, 'Failed to batch fetch existing mappings') + result.errors += batch.length + // Remove failed fetches from pending validation to avoid storing them + for (const pnUser of batch) { + pendingValidation.delete(pnUser) + } + } + } + } + + // Phase 3: Store new/updated mappings + const validPairs = Array.from(pendingValidation.values()) + + if (validPairs.length === 0) { return result } - // Store in batches for better performance - const entries = Object.entries(validPairs) - const batches = this.chunkArray(entries, this.config.batchSize) + const storeBatches = this.chunkArray(validPairs, this.config.batchSize) - for (const batch of batches) { + for (const batch of storeBatches) { try { await this.retryOperation(async () => { await this.keys.transaction(async () => { - for (const [pnUser, lidUser] of batch) { + for (const { pnUser, lidUser } of batch) { await this.keys.set({ 'lid-mapping': { [pnUser]: lidUser, @@ -422,7 +454,7 @@ export class LIDMappingStore { } } - this.logger.trace({ result, totalPairs: pairs.length }, 'Stored LID-PN mappings') + this.logger.trace({ result, totalPairs: pairs.length, cacheMisses: cacheMissPnUsers.length }, 'Stored LID-PN mappings with batch optimization') this.recordMetrics('store', result.stored) return result @@ -454,7 +486,7 @@ export class LIDMappingStore { const pendingByPnUser = new Map }>>() for (const pn of pns) { - if (!isPnUser(pn) && !isHostedPnUser(pn)) continue + if (!isAnyPnUser(pn)) continue const decoded = jidDecode(pn) if (!decoded) continue @@ -620,7 +652,7 @@ export class LIDMappingStore { } for (const lid of lids) { - if (!isLidUser(lid) && !isHostedLidUser(lid)) continue + if (!isAnyLidUser(lid)) continue const decoded = jidDecode(lid) if (!decoded) continue @@ -705,7 +737,7 @@ export class LIDMappingStore { async hasMappingForPN(pn: string): Promise { this.checkDestroyed() - if (!isPnUser(pn) && !isHostedPnUser(pn)) return false + if (!isAnyPnUser(pn)) return false const decoded = jidDecode(pn) if (!decoded) return false @@ -735,7 +767,7 @@ export class LIDMappingStore { async deleteMappingFromCache(pn: string): Promise { this.checkDestroyed() - if (!isPnUser(pn) && !isHostedPnUser(pn)) return false + if (!isAnyPnUser(pn)) return false const decoded = jidDecode(pn) if (!decoded) return false @@ -807,7 +839,7 @@ export class LIDMappingStore { * Checks that one is a LID and the other is a PN (in either order) */ private isValidMapping(lid: string, pn: string): boolean { - return (isLidUser(lid) && isPnUser(pn)) || (isPnUser(lid) && isLidUser(pn)) || false + return (isAnyLidUser(lid) && isAnyPnUser(pn)) || (isAnyPnUser(lid) && isAnyLidUser(pn)) } /** diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 3c1ab653..cd1a0ac5 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -1193,21 +1193,21 @@ export const makeChatsSocket = (config: SocketConfig) => { }, 20_000) }) - ev.on('lid-mapping.update', async ({ lid, pn }) => { + ev.on('lid-mapping.update', async (mappings) => { try { - const result = await signalRepository.lidMapping.storeLIDPNMappings([{ lid, pn }]) + const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings) logger.debug( - { lid, pn, stored: result.stored, skipped: result.skipped, errors: result.errors }, - 'stored LID-PN mapping from update event' + { count: mappings.length, stored: result.stored, skipped: result.skipped, errors: result.errors }, + 'stored LID-PN mappings from update event' ) if (result.stored > 0) { logger.info( - { lid, pn }, - 'fallback LID mapping is now available from update event' + { count: mappings.length, stored: result.stored }, + 'fallback LID mappings are now available from update event' ) } } catch (error) { - logger.warn({ lid, pn, error }, 'Failed to store LID-PN mapping') + logger.warn({ count: mappings.length, error }, 'Failed to store LID-PN mappings') } }) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index f3508f94..2671aac3 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -49,6 +49,8 @@ import { type FullJid, getBinaryNodeChild, getBinaryNodeChildren, + isAnyLidUser, + isAnyPnUser, isHostedLidUser, isHostedPnUser, isJidBot, @@ -167,7 +169,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { node.attrs.t = unixTimestampSeconds().toString() } - if (type === 'sender' && (isPnUser(jid) || isLidUser(jid))) { + if (type === 'sender' && (isAnyPnUser(jid) || isAnyLidUser(jid))) { node.attrs.recipient = jid node.attrs.to = participant! } else { @@ -289,7 +291,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const requestedLidUsers = new Set() for (const jid of toFetch) { - if (isLidUser(jid) || isHostedLidUser(jid)) { + if (isAnyLidUser(jid)) { const user = jidDecode(jid)?.user if (user) requestedLidUsers.add(user) } @@ -453,10 +455,10 @@ export const makeMessagesSocket = (config: SocketConfig) => { if (jidsRequiringFetch.length) { // LID if mapped, otherwise original const wireJids = [ - ...jidsRequiringFetch.filter(jid => !!isLidUser(jid) || !!isHostedLidUser(jid)), + ...jidsRequiringFetch.filter(jid => isAnyLidUser(jid)), ...( (await signalRepository.lidMapping.getLIDsForPNs( - jidsRequiringFetch.filter(jid => !!isPnUser(jid) || !!isHostedPnUser(jid)) + jidsRequiringFetch.filter(jid => isAnyPnUser(jid)) )) || [] ).map(a => a.lid) ] @@ -1110,7 +1112,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { } if (isRetryResend) { - const isParticipantLid = isLidUser(participant!.jid) + const isParticipantLid = isAnyLidUser(participant!.jid) const isMe = areJidsSameUser(participant!.jid, isParticipantLid ? meLid : meId) const encodedMessageToSend = isMe @@ -1243,8 +1245,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { // IMPORTANT: Carousels and catalog messages should NOT have bot node // as they are regular interactive messages, not bot messages const isPrivateUserChat = ( - isPnUser(destinationJid) || - isLidUser(destinationJid) || + isAnyPnUser(destinationJid) || + isAnyLidUser(destinationJid) || destinationJid?.endsWith('@c.us') ) && !isJidBot(destinationJid) diff --git a/src/Types/Events.ts b/src/Types/Events.ts index 64e2090d..d15fa6c7 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -36,7 +36,7 @@ export type BaileysEventMap = { 'chats.upsert': Chat[] /** update the given chats */ 'chats.update': ChatUpdate[] - 'lid-mapping.update': LIDMapping + 'lid-mapping.update': LIDMapping[] /** delete chats with given ID */ 'chats.delete': string[] /** presence of contact in a chat updated */ diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 65f11571..b8fcfd91 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -905,7 +905,7 @@ export const processSyncAction = ( ev.emit('settings.update', { setting: 'timeFormat', value: action.timeFormatAction }) } else if (action?.pnForLidChatAction) { if (action.pnForLidChatAction.pnJid) { - ev.emit('lid-mapping.update', { lid: id!, pn: action.pnForLidChatAction.pnJid }) + ev.emit('lid-mapping.update', [{ lid: id!, pn: action.pnForLidChatAction.pnJid }]) } } else if (action?.privacySettingRelayAllCalls) { ev.emit('settings.update', { diff --git a/src/Utils/history.ts b/src/Utils/history.ts index df174892..c35c15fe 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -8,13 +8,11 @@ import { normalizeMessageContent } from './messages' import { downloadContentFromMessage } from './messages-media' import type { ILogger } from './logger.js' import { - isHostedLidUser, - isHostedPnUser, + isAnyLidUser, + isAnyPnUser, isJidBroadcast, isJidGroup, isJidNewsletter, - isLidUser, - isPnUser, jidDecode, jidNormalizedUser } from '../WABinary/index.js' @@ -62,12 +60,7 @@ export function isPersonJid(jid: string | undefined): boolean { } // Only person JIDs (LID or PN formats) can have mappings - return !!( - isLidUser(jid) || - isHostedLidUser(jid) || - isPnUser(jid) || - isHostedPnUser(jid) - ) + return isAnyLidUser(jid) || isAnyPnUser(jid) } /** @@ -116,9 +109,9 @@ export function extractLidPnFromConversation( } // Check if chat ID is in LID format - const chatIsLid = isLidUser(chatId) || isHostedLidUser(chatId) + const chatIsLid = isAnyLidUser(chatId) // Check if chat ID is in PN format - const chatIsPn = isPnUser(chatId) || isHostedPnUser(chatId) + const chatIsPn = isAnyPnUser(chatId) if (chatIsLid && pnJid) { // Chat ID is LID, pnJid contains the phone number @@ -229,7 +222,7 @@ const extractPnFromMessages = (messages: proto.IHistorySyncMsg[]): string | unde } const userJid = message.userReceipt[0]?.userJid - if (userJid && (isPnUser(userJid) || isHostedPnUser(userJid))) { + if (userJid && isAnyPnUser(userJid)) { return userJid } } @@ -312,7 +305,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger ) if (conversationMapping) { addLidPnMapping(conversationMapping) - } else if ((isLidUser(chatId) || isHostedLidUser(chatId)) && !chat.pnJid) { + } else if (isAnyLidUser(chatId) && !chat.pnJid) { // Source 2b: Fallback - extract PN from userReceipt in messages when pnJid is missing // This handles edge cases where the conversation is LID but pnJid wasn't provided const pnFromReceipt = extractPnFromMessages(chat.messages || []) diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index c5cecd8a..7cdc16a0 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -342,9 +342,9 @@ const processMessage = async ( logger?.warn({ error }, 'Failed to store LID-PN mappings from history sync') } - // eslint-disable-next-line max-depth - for (const mapping of data.lidPnMappings) { - ev.emit('lid-mapping.update', mapping) + // Emit all mappings at once for better performance + if (data.lidPnMappings.length > 0) { + ev.emit('lid-mapping.update', data.lidPnMappings) } } diff --git a/src/Utils/sync-action-utils.ts b/src/Utils/sync-action-utils.ts index 14ecd310..2c4ac1a3 100644 --- a/src/Utils/sync-action-utils.ts +++ b/src/Utils/sync-action-utils.ts @@ -56,7 +56,7 @@ export const processContactAction = ( if (lidJid && isLidUser(lidJid) && idIsPn) { results.push({ event: 'lid-mapping.update', - data: { lid: lidJid, pn: id } + data: [{ lid: lidJid, pn: id }] }) } diff --git a/src/WABinary/jid-utils.ts b/src/WABinary/jid-utils.ts index d6995879..bcfa988f 100644 --- a/src/WABinary/jid-utils.ts +++ b/src/WABinary/jid-utils.ts @@ -105,6 +105,12 @@ export const isJidNewsletter = (jid: string | undefined) => jid?.endsWith('@news export const isHostedPnUser = (jid: string | undefined) => jid?.endsWith('@hosted') /** is the jid a hosted LID */ export const isHostedLidUser = (jid: string | undefined) => jid?.endsWith('@hosted.lid') +/** is the jid any LID (regular or hosted) */ +export const isAnyLidUser = (jid: string | undefined) => + !!(isLidUser(jid) || isHostedLidUser(jid)) +/** is the jid any PN user (regular or hosted) */ +export const isAnyPnUser = (jid: string | undefined) => + !!(isPnUser(jid) || isHostedPnUser(jid)) const botRegexp = /^1313555\d{4}$|^131655500\d{2}$/ diff --git a/src/__tests__/Utils/process-sync-action.test.ts b/src/__tests__/Utils/process-sync-action.test.ts index 2da93d30..ba19d76d 100644 --- a/src/__tests__/Utils/process-sync-action.test.ts +++ b/src/__tests__/Utils/process-sync-action.test.ts @@ -158,10 +158,10 @@ describe('processSyncAction', () => { phoneNumber: '5511999@s.whatsapp.net' } ]) - expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', { + expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', [{ lid: '123@lid', pn: '5511999@s.whatsapp.net' - }) + }]) }) it('does not emit events when id is missing', () => { @@ -298,7 +298,7 @@ describe('processSyncAction', () => { '123@lid' ]) processSyncAction(syncAction, ev, mockMe, undefined, logger) - expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', { lid: '123@lid', pn: '5511999@s.whatsapp.net' }) + expect(ev.emit).toHaveBeenCalledWith('lid-mapping.update', [{ lid: '123@lid', pn: '5511999@s.whatsapp.net' }]) }) it('does not emit when pnJid is missing', () => { diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts index fbf54303..3e96611c 100644 --- a/src/__tests__/Utils/sync-action-utils.test.ts +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -76,7 +76,7 @@ describe('processContactAction', () => { expect(results).toContainEqual({ event: 'lid-mapping.update', - data: { lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' } + data: [{ lid: '123456789@lid', pn: '5511999999999@s.whatsapp.net' }] }) }) @@ -88,7 +88,7 @@ describe('processContactAction', () => { expect(results).toContainEqual({ event: 'lid-mapping.update', - data: { lid: '173233882013816:99@lid', pn: '5511999999999@s.whatsapp.net' } + data: [{ lid: '173233882013816:99@lid', pn: '5511999999999@s.whatsapp.net' }] }) }) diff --git a/src/__tests__/WABinary/jid-utils.test.ts b/src/__tests__/WABinary/jid-utils.test.ts new file mode 100644 index 00000000..456445d3 --- /dev/null +++ b/src/__tests__/WABinary/jid-utils.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from '@jest/globals' +import { isAnyLidUser, isAnyPnUser, isHostedLidUser, isHostedPnUser, isLidUser, isPnUser } from '../../WABinary/jid-utils' + +describe('JID Utility Functions', () => { + describe('isAnyLidUser', () => { + it('should return true for regular LID users', () => { + expect(isAnyLidUser('1234567890@lid')).toBe(true) + expect(isAnyLidUser('1234567890:1@lid')).toBe(true) + expect(isAnyLidUser('1234567890:99@lid')).toBe(true) + }) + + it('should return true for hosted LID users', () => { + expect(isAnyLidUser('1234567890@hosted.lid')).toBe(true) + expect(isAnyLidUser('1234567890:1@hosted.lid')).toBe(true) + expect(isAnyLidUser('1234567890:99@hosted.lid')).toBe(true) + }) + + it('should return false for PN users', () => { + expect(isAnyLidUser('1234567890@s.whatsapp.net')).toBe(false) + expect(isAnyLidUser('1234567890:1@s.whatsapp.net')).toBe(false) + }) + + it('should return false for hosted PN users', () => { + expect(isAnyLidUser('1234567890@hosted')).toBe(false) + expect(isAnyLidUser('1234567890:1@hosted')).toBe(false) + }) + + it('should return false for groups', () => { + expect(isAnyLidUser('123456789@g.us')).toBe(false) + }) + + it('should return false for newsletters', () => { + expect(isAnyLidUser('123456789@newsletter')).toBe(false) + }) + + it('should return false for broadcasts', () => { + expect(isAnyLidUser('status@broadcast')).toBe(false) + }) + + it('should return false for undefined or empty strings', () => { + expect(isAnyLidUser(undefined)).toBe(false) + expect(isAnyLidUser('')).toBe(false) + }) + + it('should handle edge cases', () => { + expect(isAnyLidUser('invalid')).toBe(false) + expect(isAnyLidUser('@lid')).toBe(true) + expect(isAnyLidUser('@hosted.lid')).toBe(true) + }) + }) + + describe('isAnyPnUser', () => { + it('should return true for regular PN users', () => { + expect(isAnyPnUser('1234567890@s.whatsapp.net')).toBe(true) + expect(isAnyPnUser('1234567890:1@s.whatsapp.net')).toBe(true) + expect(isAnyPnUser('1234567890:99@s.whatsapp.net')).toBe(true) + }) + + it('should return true for hosted PN users', () => { + expect(isAnyPnUser('1234567890@hosted')).toBe(true) + expect(isAnyPnUser('1234567890:1@hosted')).toBe(true) + expect(isAnyPnUser('1234567890:99@hosted')).toBe(true) + }) + + it('should return false for LID users', () => { + expect(isAnyPnUser('1234567890@lid')).toBe(false) + expect(isAnyPnUser('1234567890:1@lid')).toBe(false) + }) + + it('should return false for hosted LID users', () => { + expect(isAnyPnUser('1234567890@hosted.lid')).toBe(false) + expect(isAnyPnUser('1234567890:1@hosted.lid')).toBe(false) + }) + + it('should return false for groups', () => { + expect(isAnyPnUser('123456789@g.us')).toBe(false) + }) + + it('should return false for newsletters', () => { + expect(isAnyPnUser('123456789@newsletter')).toBe(false) + }) + + it('should return false for broadcasts', () => { + expect(isAnyPnUser('status@broadcast')).toBe(false) + }) + + it('should return false for undefined or empty strings', () => { + expect(isAnyPnUser(undefined)).toBe(false) + expect(isAnyPnUser('')).toBe(false) + }) + + it('should handle edge cases', () => { + expect(isAnyPnUser('invalid')).toBe(false) + expect(isAnyPnUser('@s.whatsapp.net')).toBe(true) + expect(isAnyPnUser('@hosted')).toBe(true) + }) + }) + + describe('isAnyLidUser vs individual checks', () => { + it('should be equivalent to !!(isLidUser || isHostedLidUser)', () => { + const testCases = [ + '1234567890@lid', + '1234567890@hosted.lid', + '1234567890@s.whatsapp.net', + '1234567890@hosted', + '123@g.us', + undefined, + '' + ] + + for (const jid of testCases) { + const anyLidResult = isAnyLidUser(jid) + const individualResult = !!(isLidUser(jid) || isHostedLidUser(jid)) + expect(anyLidResult).toBe(individualResult) + } + }) + }) + + describe('isAnyPnUser vs individual checks', () => { + it('should be equivalent to !!(isPnUser || isHostedPnUser)', () => { + const testCases = [ + '1234567890@s.whatsapp.net', + '1234567890@hosted', + '1234567890@lid', + '1234567890@hosted.lid', + '123@g.us', + undefined, + '' + ] + + for (const jid of testCases) { + const anyPnResult = isAnyPnUser(jid) + const individualResult = !!(isPnUser(jid) || isHostedPnUser(jid)) + expect(anyPnResult).toBe(individualResult) + } + }) + }) + + describe('Complementary behavior', () => { + it('isAnyLidUser and isAnyPnUser should be mutually exclusive for valid JIDs', () => { + const lidJids = ['1234567890@lid', '1234567890:1@hosted.lid'] + const pnJids = ['1234567890@s.whatsapp.net', '1234567890:1@hosted'] + + for (const jid of lidJids) { + expect(isAnyLidUser(jid)).toBe(true) + expect(isAnyPnUser(jid)).toBe(false) + } + + for (const jid of pnJids) { + expect(isAnyLidUser(jid)).toBe(false) + expect(isAnyPnUser(jid)).toBe(true) + } + }) + + it('both should return false for non-user JIDs', () => { + const nonUserJids = ['123@g.us', '456@newsletter', 'status@broadcast'] + + for (const jid of nonUserJids) { + expect(isAnyLidUser(jid)).toBe(false) + expect(isAnyPnUser(jid)).toBe(false) + } + }) + }) + + describe('Device ID handling', () => { + it('should work correctly with various device IDs', () => { + const deviceIds = [0, 1, 10, 99, 100, 999] + + for (const deviceId of deviceIds) { + expect(isAnyLidUser(`123:${deviceId}@lid`)).toBe(true) + expect(isAnyLidUser(`123:${deviceId}@hosted.lid`)).toBe(true) + expect(isAnyPnUser(`123:${deviceId}@s.whatsapp.net`)).toBe(true) + expect(isAnyPnUser(`123:${deviceId}@hosted`)).toBe(true) + } + }) + + it('should work without device ID', () => { + expect(isAnyLidUser('123@lid')).toBe(true) + expect(isAnyLidUser('123@hosted.lid')).toBe(true) + expect(isAnyPnUser('123@s.whatsapp.net')).toBe(true) + expect(isAnyPnUser('123@hosted')).toBe(true) + }) + }) +})