Compare commits

...

9 Commits

Author SHA1 Message Date
Renato Alcara d28d8a4596 fix: address PR review — lint, dedup scope, and cleanup targeting
- Remove unused `autoCleanCorrupted` param from decryptMessageNode (lint fix)
- Scope retry dedup by participant JID in group chats (not group JID)
- Move dedup registration after early-return checks to avoid blocking
  subsequent messages when max retries reached
- Use participant JID for session cleanup in groups (Signal sessions
  are per-participant, not per-group)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:57:40 -03:00
Renato Alcara 17e3bd5d36 fix: break infinite Bad MAC + session recreation loop
Remove aggressive session cleanup from the decryption hot path that caused
cascading failures when multiple messages from the same contact arrived
simultaneously. The Signal Protocol naturally recovers via retry+pkmsg flow.

Changes:
- Remove cleanupCorruptedSession() from per-message error handler (hot path)
- Move session cleanup to retry-exhausted handler as safety net
- Add per-JID retry request deduplication (5s window)
- Add 10s cooldown on MAC error session recreation
- Export cleanupCorruptedSession/getDecryptionJid for use in messages-recv

Tested: corrupted session with fake keys → Bad MAC → retry receipt →
pkmsg recovery → all 7 messages delivered, zero infinite loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:29:58 -03:00
Renato Alcara e1e3d88a1c feat: centralized LID→PN normalization for all emitted events (#246)
* feat: centralized LID→PN normalization for all emitted events

WhatsApp's server increasingly uses LID (Linked ID) as the primary
addressing format. Consumers expect phone numbers (PN),
not opaque LID identifiers. This adds comprehensive LID→PN resolution
across all ev.emit() paths so downstream consumers always receive PN.

Key changes:

- Add resolveLidToPn() and normalizeKeyLidToPn() centralized helpers
  in process-message.ts for consistent LID→PN resolution

- normalizeMessageJids() fast path: use alt JID directly from stanza
  attributes (zero I/O, eliminates race condition with LIDMappingStore)

- Await storeLIDPNMappings() before normalization in message receipt
  flow (was fire-and-forget, could race with subsequent getPNForLID)

- Normalize LID→PN in all event emission points:
  * messages.upsert (keys, nested reaction/poll keys, participantAlt)
  * presence.update (jid + participant)
  * message-receipt.update (key + userJid)
  * contacts.update (picture notifications)
  * blocklist.update (blocklist JIDs)
  * call events (chatId, from)
  * group metadata (participants, owner, subjectOwner)
  * group notifications (acting participant, add/remove/promote/demote)
  * newsletter notifications (author, user JIDs)
  * sync actions (mutation index normalization)

- Make handlePresenceUpdate and handleGroupNotification async to
  support await on LID resolution

- Add normalizeGroupMetadata() helper in groups.ts for all
  extractGroupMetadata call sites

Performance: ~0.01ms per message (LRU cache hit). No impact on
message sending. First-contact resolution ~2-5ms (one-time per contact).

* fix: normalize LID→PN in handleBadAck, media retry, and PDO recovery

Additional leak points found during final audit:
- handleBadAck: key.remoteJid from ack stanza could be LID
- messages.media-update: media retry key JIDs not normalized
- CTWA PDO recovery: webMessageInfo.key from phone response not normalized

* fix: address PR review — parallelize LID resolution, use helper consistently

- normalizeGroupMetadata: resolve participant LIDs with Promise.all
  instead of sequential loop (perf on large groups)
- handleCall: resolve participant JIDs with Promise.all
- handleBadAck: use normalizeKeyLidToPn() helper instead of manual
  resolveLidToPn + assignment (consistency with other code paths)
- CB:relay: resolve callCreator LID→PN before emitting
2026-03-01 17:44:02 -03:00
Renato Alcara 758ab6d8af feat: complete WhatsApp call signaling (voice, video, call links) (#245)
feat: complete WhatsApp call signaling (voice, video, call links) (#245)
2026-03-01 14:07:43 -03:00
Renato Alcara 569e43f184 fix: use MACOS platform to resolve 405 connection failure (#244)
fix: use MACOS platform to resolve 405 connection failure
2026-03-01 10:00:20 -03:00
Renato Alcara 79ee40037b fix: add timeout to fetchLatestBaileysVersion to prevent hanging conn… (#243)
* fix: add timeout to fetchLatestBaileysVersion to prevent hanging connections

AbortController with 5s default timeout prevents indefinite blocking
when GitHub is unreachable (DNS failure, network issues). Falls back
to bundled version via existing catch block. Ref: Baileys#2385.


* fix: move clearTimeout to finally block in fetchLatestBaileysVersion

Ensures timer cleanup on both success and fetch rejection paths,
preventing dangling timers when DNS/network fails before abort fires.
2026-03-01 09:41:43 -03:00
Renato Alcara f032c9b0ea fix: normalise remoteJid in reaction/poll keys for fromMe messages (#240)
In DMs, when the connected user sends a reaction, the inner key's
remoteJid could remain unnormalized (e.g. LID vs PN), preventing
correct matching with the original message. Also normalises participant
in groups for own messages — an improvement over upstream Baileys#2386.
2026-03-01 08:46:09 -03:00
Renato Alcara 3d79b8fc36 chore: update WhatsApp Web version to v2.3000.1034279434 (#242)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-01 06:06:04 -03:00
github-actions[bot] f6e976f182 chore: update proto/version to v2.3000.1034274421 (#241)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-01 00:49:55 -03:00
11 changed files with 1363 additions and 169 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1034238531 /// WhatsApp Version: 2.3000.1034274421
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034258243]} {"version":[2,3000,1034279434]}
+40 -13
View File
@@ -50,7 +50,8 @@ import {
isMissingKeyError, isMissingKeyError,
MAX_SYNC_ATTEMPTS, MAX_SYNC_ATTEMPTS,
newLTHashState, newLTHashState,
processSyncAction processSyncAction,
resolveLidToPn
} from '../Utils' } from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex' import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message' import processMessage from '../Utils/process-message'
@@ -757,9 +758,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'resync-app-state') }, authState?.creds?.me?.id || 'resync-app-state')
const { onMutation } = newAppStateChunkHandler(isInitialSync) const { onMutation } = newAppStateChunkHandler(isInitialSync)
const lidMapping = signalRepository.lidMapping
for (const key in globalMutationMap) { for (const key in globalMutationMap) {
const mutation = globalMutationMap[key] const mutation = globalMutationMap[key]
if (!mutation) continue if (!mutation) continue
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation) onMutation(mutation)
} }
} }
@@ -912,16 +920,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
}) })
} }
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => { const handlePresenceUpdate = async ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData | undefined let presence: PresenceData | undefined
const jid = attrs.from const rawJid = attrs.from
const participant = attrs.participant || attrs.from const rawParticipant = attrs.participant || attrs.from
if (!jid) { if (!rawJid) {
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping') logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
return return
} }
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
return return
} }
@@ -933,7 +941,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
} else if (Array.isArray(content)) { } else if (Array.isArray(content)) {
const [firstChild] = content const [firstChild] = content
if (!firstChild) { if (!firstChild) {
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping') logger.warn({ jid: rawJid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return return
} }
@@ -952,12 +960,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
if (presence) { if (presence) {
if (!participant) { if (!rawParticipant) {
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping') logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
return return
} }
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } }) // Resolve LID→PN so consumers always see phone-number JIDs
const lidMapping = signalRepository.lidMapping
const [jid, participant] = await Promise.all([
resolveLidToPn(rawJid, lidMapping, logger),
resolveLidToPn(rawParticipant, lidMapping, logger)
])
ev.emit('presence.update', { id: jid!, presences: { [participant!]: presence } })
} }
} }
@@ -1031,8 +1046,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
undefined, undefined,
logger logger
) )
const lidMapping = signalRepository.lidMapping
for (const key in mutationMap) { for (const key in mutationMap) {
onMutation(mutationMap[key]!) const mutation = mutationMap[key]!
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation)
} }
} }
} }
@@ -1376,8 +1399,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
}) })
ws.on('CB:presence', handlePresenceUpdate) ws.on('CB:presence', (node: BinaryNode) => {
ws.on('CB:chatstate', handlePresenceUpdate) handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling presence update'))
})
ws.on('CB:chatstate', (node: BinaryNode) => {
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling chatstate update'))
})
ws.on('CB:ib,,dirty', async (node: BinaryNode) => { ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')! const { attrs } = getBinaryNodeChild(node, 'dirty')!
+43 -7
View File
@@ -1,7 +1,7 @@
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types' import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
import { WAMessageAddressingMode, WAMessageStubType } from '../Types' import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
import { generateMessageIDV2, unixTimestampSeconds } from '../Utils' import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
import { import {
type BinaryNode, type BinaryNode,
getBinaryNodeChild, getBinaryNodeChild,
@@ -17,6 +17,43 @@ import { makeChatsSocket } from './chats'
export const makeGroupsSocket = (config: SocketConfig) => { export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config) const sock = makeChatsSocket(config)
const { authState, ev, query, upsertMessage } = sock const { authState, ev, query, upsertMessage } = sock
const { signalRepository } = sock
const { logger } = config
/** Normalize group metadata participant IDs from LID to PN */
const normalizeGroupMetadata = async (metadata: GroupMetadata): Promise<GroupMetadata> => {
const lidMapping = signalRepository.lidMapping
// Resolve all participant LIDs in parallel for better performance on large groups
await Promise.all(metadata.participants.map(async (p) => {
if (isLidUser(p.id)) {
if (p.phoneNumber) {
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = resolved
}
}
}
}))
// Normalize owner/subjectOwner if LID (parallel)
const [resolvedOwner, resolvedSubjectOwner] = await Promise.all([
metadata.owner && isLidUser(metadata.owner)
? (metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger))
: null,
metadata.subjectOwner && isLidUser(metadata.subjectOwner)
? (metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger))
: null
])
if (resolvedOwner) metadata.owner = resolvedOwner
if (resolvedSubjectOwner) metadata.subjectOwner = resolvedSubjectOwner
return metadata
}
const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) => const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) =>
query({ query({
@@ -31,7 +68,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const groupMetadata = async (jid: string) => { const groupMetadata = async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }]) const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
return extractGroupMetadata(result) return normalizeGroupMetadata(extractGroupMetadata(result))
} }
const groupFetchAllParticipating = async () => { const groupFetchAllParticipating = async () => {
@@ -58,16 +95,15 @@ export const makeGroupsSocket = (config: SocketConfig) => {
if (groupsChild) { if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group') const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) { for (const groupNode of groups) {
const meta = extractGroupMetadata({ const meta = await normalizeGroupMetadata(extractGroupMetadata({
tag: 'result', tag: 'result',
attrs: {}, attrs: {},
content: [groupNode] content: [groupNode]
}) }))
data[meta.id] = meta data[meta.id] = meta
} }
} }
// TODO: properly parse LID / PN DATA
sock.ev.emit('groups.update', Object.values(data)) sock.ev.emit('groups.update', Object.values(data))
return data return data
@@ -101,7 +137,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
})) }))
} }
]) ])
return extractGroupMetadata(result) return normalizeGroupMetadata(extractGroupMetadata(result))
}, },
groupLeave: async (id: string) => { groupLeave: async (id: string) => {
await groupQuery('@g.us', 'set', [ await groupQuery('@g.us', 'set', [
@@ -277,7 +313,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
), ),
groupGetInviteInfo: async (code: string) => { groupGetInviteInfo: async (code: string) => {
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }]) const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
return extractGroupMetadata(results) return normalizeGroupMetadata(extractGroupMetadata(results))
}, },
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => { groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration const content: BinaryNode = ephemeralExpiration
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -1,4 +1,30 @@
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate' export type WACallUpdateType =
| 'offer'
| 'ringing'
| 'timeout'
| 'reject'
| 'accept'
| 'terminate'
| 'preaccept'
| 'transport'
| 'relaylatency'
| 'group_update'
| 'reminder'
| 'heartbeat'
| 'mute_v2'
| 'enc_rekey'
| 'video'
| 'relay'
export type WACallParticipant = {
jid?: string
/** 'connected' | 'invited' | 'left' etc. */
state?: string
/** Phone number in s.whatsapp.net format */
userPn?: string
/** 'admin' for group call link creator */
type?: string
}
export type WACallEvent = { export type WACallEvent = {
chatId: string chatId: string
@@ -13,4 +39,20 @@ export type WACallEvent = {
latencyMs?: number latencyMs?: number
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */ /** Phone number of the caller (sanitized to fix Brazilian landline bug) */
callerPn?: string callerPn?: string
/** Call link token (forms URL: https://call.whatsapp.com/video/<token>) */
linkToken?: string
/** JID of who created the call link */
linkCreator?: string
/** Phone number of link creator */
linkCreatorPn?: string
/** Media type: 'video' or 'audio' */
media?: string
/** Max participants for group/link calls */
connectedLimit?: number
/** Participants in a group/link call */
participants?: WACallParticipant[]
/** Call duration in ms (from terminate/call_summary) */
duration?: number
/** Terminate reason (e.g. 'group_call_ended', 'accepted_elsewhere', 'timeout') */
terminateReason?: string
} }
+14 -26
View File
@@ -276,7 +276,6 @@ export const decryptMessageNode = (
meLid: string, meLid: string,
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger, logger: ILogger,
autoCleanCorrupted = true
) => { ) => {
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid) const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
return { return {
@@ -422,34 +421,19 @@ export const decryptMessageNode = (
if (isRetryExhausted) { if (isRetryExhausted) {
logger.error( logger.error(
errorContext, errorContext,
`⚠️ Session corrupted and recovery failed after ${err.attempts} attempts. Auto-cleanup will attempt to recover.` `⚠️ Session corrupted after ${err.attempts} attempts. Retry+pkmsg flow will recover.`
) )
} else { } else {
// First occurrence - log as warning since auto-recovery will attempt // First occurrence - log as warning since auto-recovery will attempt
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery') logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
} }
// Automatic cleanup of corrupted session (if enabled) // Session cleanup is deferred to retry exhaustion (safety net).
// eslint-disable-next-line max-depth // The Signal Protocol handles recovery naturally via retry+pkmsg:
if (autoCleanCorrupted) { // Bad MAC -> retry receipt -> sender re-sends as pkmsg -> new session.
// eslint-disable-next-line max-depth // Deleting sessions here (hot path) causes cascading failures when
try { // multiple messages from the same contact arrive simultaneously.
const deletedCount = await cleanupCorruptedSession(decryptionJid, repository, logger) // See: messages-recv.ts sendRetryRequest() for deferred cleanup.
// Mask only user portion of JID for privacy (preserve domain info)
const { user, server } = jidDecode(decryptionJid) || {}
const maskedUser =
user && user.length > 8 ? `${user.substring(0, 4)}****${user.substring(user.length - 4)}` : user
const maskedJid = maskedUser && server ? `${maskedUser}@${server}` : decryptionJid
logger.info(
{ jid: decryptionJid, maskedJid, targetedDevices: deletedCount },
`🔄 Session Reset | JID: ${maskedJid} | Targeted: ${deletedCount} devices | Will recreate on next message`
)
} catch (cleanupErr) {
logger.error({ decryptionJid, err: cleanupErr }, '❌ Failed to cleanup corrupted session')
}
}
} else if (isSessionRecord) { } else if (isSessionRecord) {
// Session record errors are transient - retry should handle them // Session record errors are transient - retry should handle them
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
@@ -504,10 +488,14 @@ export function isCorruptedSessionError(error: any): boolean {
} }
/** /**
* Clean up corrupted session by deleting all device sessions for a JID * Clean up corrupted session by deleting all device sessions for a JID.
* Signal Protocol will automatically recreate the session on next message * Signal Protocol will automatically recreate the session on next message.
*
* NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
* Only call this as a safety net when retries are exhausted.
*/ */
async function cleanupCorruptedSession( export async function cleanupCorruptedSession(
jid: string, jid: string,
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger logger: ILogger
+44 -6
View File
@@ -230,14 +230,22 @@ export const bindWaitForConnectionUpdate = (ev: BaileysEventEmitter) => bindWait
* utility that fetches latest baileys version from the master branch. * utility that fetches latest baileys version from the master branch.
* Use to ensure your WA connection is always on the latest version * Use to ensure your WA connection is always on the latest version
*/ */
export const fetchLatestBaileysVersion = async (options: RequestInit = {}) => { export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout?: number } = {}) => {
const URL = 'https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/index.ts' const URL = 'https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/index.ts'
try { try {
const response = await fetch(URL, { const controller = new AbortController()
dispatcher: options.dispatcher, const timeout = setTimeout(() => controller.abort(), options.timeout ?? 5000)
method: 'GET', let response: Response
headers: options.headers try {
}) response = await fetch(URL, {
dispatcher: options.dispatcher,
method: 'GET',
headers: options.headers,
signal: controller.signal
})
} finally {
clearTimeout(timeout)
}
if (!response.ok) { if (!response.ok) {
throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status }) throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status })
} }
@@ -421,6 +429,36 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
case 'accept': case 'accept':
status = 'accept' status = 'accept'
break break
case 'preaccept':
status = 'preaccept'
break
case 'transport':
status = 'transport'
break
case 'relaylatency':
status = 'relaylatency'
break
case 'group_update':
status = 'group_update'
break
case 'reminder':
status = 'reminder'
break
case 'heartbeat':
status = 'heartbeat'
break
case 'mute_v2':
status = 'mute_v2'
break
case 'enc_rekey':
status = 'enc_rekey'
break
case 'video':
status = 'video'
break
case 'relay':
status = 'relay'
break
default: default:
status = 'ringing' status = 'ringing'
break break
+22 -4
View File
@@ -211,15 +211,33 @@ export class MessageRetryManager {
} }
} }
// MAC errors require IMMEDIATE session recreation regardless of history // MAC errors require session recreation, but rate-limited to prevent loops.
// This handles the case where contact reinstalled WhatsApp (identity key changed) // When multiple messages fail with Bad MAC simultaneously, only the first
// should trigger recreation; subsequent ones within the cooldown window
// piggyback on the same new session established by the retry+pkmsg flow.
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
this.sessionRecreateHistory.set(jid, Date.now()) const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
this.logger.debug(
{ jid, errorCode: reasonName, msSinceLast: now - prevTime },
'MAC error session recreation skipped — cooldown active'
)
return {
reason: '',
recreate: false
}
}
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++ this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}` const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalMacErrors?.inc({ action: 'session_recreation' }) metrics.signalMacErrors?.inc({ action: 'session_recreation' })
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' }) metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, forcing immediate session recreation') this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, recreating session')
return { return {
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`, reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
recreate: true recreate: true
+101 -28
View File
@@ -19,10 +19,13 @@ import type {
WAMessage, WAMessage,
WAMessageKey WAMessageKey
} from '../Types' } from '../Types'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import { WAMessageStubType } from '../Types' import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages' import { getContentType, normalizeMessageContent } from '../Utils/messages'
import { import {
areJidsSameUser, areJidsSameUser,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser, isHostedLidUser,
isHostedPnUser, isHostedPnUser,
isJidBroadcast, isJidBroadcast,
@@ -113,46 +116,106 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
msgKey.remoteJid = message.key.remoteJid msgKey.remoteJid = message.key.remoteJid
// set participant of the message // set participant of the message
msgKey.participant = msgKey.participant || message.key.participant msgKey.participant = msgKey.participant || message.key.participant
} else {
// fromMe reactions/polls: normalise remoteJid to match the chat JID
// ensures DM reaction keys are consistent with group behavior
msgKey.remoteJid = message.key.remoteJid
// in groups, normalise participant for own messages too
if (message.key.participant) {
msgKey.participant = msgKey.participant || message.key.participant
}
} }
} }
} }
/**
* Resolves a LID JID to its PN equivalent using the LID mapping store.
* Returns the original JID if it's not a LID or if no mapping is found.
* Safe to call with any JID type (group, newsletter, PN, etc.).
*/
export const resolveLidToPn = async (
jid: string | undefined | null,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isAnyLidUser(jid)) {
const pn = await lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
}
return pn || jid
}
return jid
}
/**
* Normalizes a WAMessageKey by resolving LID→PN for remoteJid and participant.
*/
export const normalizeKeyLidToPn = async (
key: WAMessageKey,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<void> => {
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(key.remoteJid, lidMapping, logger),
resolveLidToPn(key.participant, lidMapping, logger)
])
if (resolvedRemoteJid) {
key.remoteJid = resolvedRemoteJid
}
if (resolvedParticipant) {
key.participant = resolvedParticipant
}
}
export const normalizeMessageJids = async ( export const normalizeMessageJids = async (
message: WAMessage, message: WAMessage,
signalRepository: SignalRepositoryWithLIDStore, signalRepository: SignalRepositoryWithLIDStore,
logger?: ILogger logger?: ILogger
): Promise<void> => { ): Promise<void> => {
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => { const lidMapping = signalRepository.lidMapping
if (!jid) { const key = message.key
return undefined
}
if (isLidUser(jid) || isHostedLidUser(jid)) { // FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
const pn = await signalRepository.lidMapping.getPNForLID(jid) // The stanza always carries both formats (LID + PN) in the attributes.
if (pn) { // When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message') // When addressing_mode=pn, remoteJid is already PN (no conversion needed).
} else { if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID') logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
} key.remoteJid = key.remoteJidAlt
return pn || jid
}
return jid
} }
// Execute both lookups in parallel instead of sequentially to reduce latency if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([ logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
resolveLidToPn(message.key.remoteJid), key.participant = key.participantAlt
resolveLidToPn(message.key.participant)
])
if (resolvedRemoteJid) {
message.key.remoteJid = resolvedRemoteJid
} }
if (resolvedParticipant) { // SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
message.key.participant = resolvedParticipant await normalizeKeyLidToPn(key, lidMapping, logger)
// Also normalize participantAlt (the alternative JID format — can be LID when addressing_mode=pn)
if (key.participantAlt && isAnyLidUser(key.participantAlt)) {
const resolved = await resolveLidToPn(key.participantAlt, lidMapping, logger)
if (resolved) {
key.participantAlt = resolved
}
}
// Normalize nested message keys (reaction, poll) that may contain LID JIDs
const content = normalizeMessageContent(message.message)
if (content?.reactionMessage?.key) {
await normalizeKeyLidToPn(content.reactionMessage.key, lidMapping, logger)
}
if (content?.pollUpdateMessage?.pollCreationMessageKey) {
await normalizeKeyLidToPn(content.pollUpdateMessage.pollCreationMessageKey, lidMapping, logger)
} }
} }
@@ -526,6 +589,12 @@ const processMessage = async (
'CTWA: Successfully recovered message via placeholder resend' 'CTWA: Successfully recovered message via placeholder resend'
) )
// Normalize LID→PN in PDO-recovered message key before emitting
// eslint-disable-next-line max-depth
if (webMessageInfo.key && signalRepository) {
await normalizeKeyLidToPn(webMessageInfo.key as WAMessageKey, signalRepository.lidMapping, logger)
}
// wait till another upsert event is available, don't want it to be part of the PDO response message // wait till another upsert event is available, don't want it to be part of the PDO response message
// TODO: parse through proper message handling utilities (to add relevant key fields) // TODO: parse through proper message handling utilities (to add relevant key fields)
ev.emit('messages.upsert', { ev.emit('messages.upsert', {
@@ -666,9 +735,13 @@ const processMessage = async (
response: responseMsg response: responseMsg
} }
// Normalize creationMsgKey JIDs for the emitted event
const normalizedCreationKey = { ...creationMsgKey }
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
ev.emit('messages.update', [ ev.emit('messages.update', [
{ {
key: creationMsgKey, key: normalizedCreationKey,
update: { update: {
eventResponses: [eventResponse] eventResponses: [eventResponse]
} }
@@ -711,7 +784,7 @@ const processMessage = async (
}) })
} }
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
switch (message.messageStubType) { switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER: case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER:
+1 -1
View File
@@ -20,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
secondary: config.version[1], secondary: config.version[1],
tertiary: config.version[2] tertiary: config.version[2]
}, },
platform: proto.ClientPayload.UserAgent.Platform.WEB, platform: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1', osVersion: '0.1',
device: 'Desktop', device: 'Desktop',