Compare commits

...

8 Commits

Author SHA1 Message Date
Renato Alcara 27b942f03c feat: change default browser to Android (SMB_ANDROID)
Default is now Browsers.android('14') matching upstream PR #2201.
Pair code auto-detects Android and falls back to Chrome (socket.ts).
Set BAILEYS_BROWSER=chrome to force Chrome/macOS if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:43:24 -03:00
Renato Alcara 0eae108ba4 fix: move connection log to CB:success and rename type to platformType
Move the presentation log from pre-handshake (validateConnection) to
CB:success where the connection is actually confirmed. Rename ambiguous
'type' label to 'platformType' matching DeviceProps.PlatformType.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:42:40 -03:00
Renato Alcara cb80febba5 feat: add connection and pair code presentation logs
Single-line logs showing how the lib presents to WhatsApp:
- 📱/🖥️ Connection: phone number, platform, device, type
- 🔗 Pair code: companion platform, android override status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:22:12 -03:00
Renato Alcara 31ac5aeb11 feat: add Android browser preset for companion device registration (#248)
* feat: add Android browser preset for companion device registration

Add Browsers.android(apiLevel) preset that identifies as an Android
companion device (SMB_ANDROID platform, ANDROID_PHONE device type).
Validated against Frida captures of real WhatsApp Android protocol.


* feat: support BAILEYS_BROWSER env var for browser selection

Allows selecting Android companion mode via environment variable
without code changes. Set BAILEYS_BROWSER=android (or android:15
for a specific API level) to register as an Android device.

* fix: auto-detect Android browser in pair code and fall back to Chrome

Pair code companion registration fails with Android browser because the
WA server rejects the ANDROID_PHONE companion_platform_id over the web
Noise protocol. When Android is detected, requestPairingCode() now
transparently uses Chrome/macOS platform values for the pair code stanza
while QR code pairing continues to work as Android (SMB_ANDROID).

* docs: add android to isValidBrowserPreset JSDoc
2026-03-02 12:02:08 -03:00
Renato Alcara 45885c7a01 chore: update WhatsApp Web version to v2.3000.1034300341 (#250)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-02 06:14:04 -03:00
github-actions[bot] f1b43e26a1 chore: update proto/version to v2.3000.1034293476 (#249)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-02 00:45:19 -03:00
Renato Alcara a3dd21c9d7 fix: break infinite Bad MAC + session recreation loop (#247)
* 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.


* 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)
2026-03-01 20:59:47 -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
13 changed files with 520 additions and 139 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1034274421 /// WhatsApp Version: 2.3000.1034293476
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034279434]} {"version":[2,3000,1034300341]}
+24 -1
View File
@@ -56,10 +56,33 @@ export const PROCESSABLE_HISTORY_TYPES = [
// 6 hours in milliseconds // 6 hours in milliseconds
const SIX_HOURS_MS = 6 * 60 * 60 * 1000 const SIX_HOURS_MS = 6 * 60 * 60 * 1000
/**
* Resolves the default browser tuple from the BAILEYS_BROWSER env var.
* Default: Android companion (SMB_ANDROID) matches upstream PR #2201.
* Pair code auto-detects Android and falls back to Chrome in socket.ts.
*
* unset / 'android' Browsers.android('14')
* 'android:15' Browsers.android('15')
* 'chrome' / 'macos' Browsers.macOS('Chrome')
*/
const resolveDefaultBrowser = (): [string, string, string] => {
const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase()
if (env === 'chrome' || env === 'macos') {
return Browsers.macOS('Chrome')
}
if (env?.startsWith('android:')) {
const apiLevel = env.split(':')[1] || '14'
return Browsers.android(apiLevel)
}
return Browsers.android('14')
}
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: version as WAVersion, version: version as WAVersion,
versionCheckIntervalMs: SIX_HOURS_MS, versionCheckIntervalMs: SIX_HOURS_MS,
browser: Browsers.macOS('Chrome'), browser: resolveDefaultBrowser(),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000, connectTimeoutMs: 20_000,
keepAliveIntervalMs: 15_000, keepAliveIntervalMs: 15_000,
+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
+231 -44
View File
@@ -31,6 +31,7 @@ import {
aesDecryptCTR, aesDecryptCTR,
aesEncryptGCM, aesEncryptGCM,
cleanMessage, cleanMessage,
cleanupCorruptedSession,
Curve, Curve,
decodeMediaRetryNode, decodeMediaRetryNode,
decodeMessageNode, decodeMessageNode,
@@ -41,6 +42,7 @@ import {
encodeSignedDeviceIdentity, encodeSignedDeviceIdentity,
extractAddressingContext, extractAddressingContext,
getCallStatusFromNode, getCallStatusFromNode,
getDecryptionJid,
getHistoryMsg, getHistoryMsg,
getNextPreKeys, getNextPreKeys,
getStatusFromReceiptType, getStatusFromReceiptType,
@@ -49,7 +51,9 @@ import {
MISSING_KEYS_ERROR_TEXT, MISSING_KEYS_ERROR_TEXT,
NACK_REASONS, NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT, NO_MESSAGE_FOUND_ERROR_TEXT,
normalizeKeyLidToPn,
normalizeMessageJids, normalizeMessageJids,
resolveLidToPn,
SERVER_ERROR_CODES, SERVER_ERROR_CODES,
toNumber, toNumber,
unixTimestampSeconds, unixTimestampSeconds,
@@ -160,6 +164,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts' const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
const tcTokenKnownJids = new Set<string>() const tcTokenKnownJids = new Set<string>()
const tcTokenRetriedMsgIds = new Set<string>() const tcTokenRetriedMsgIds = new Set<string>()
// Deduplicates retry requests per JID within a short window.
// When a burst of Bad MAC errors arrives for the same contact,
// only the first retry request is sent — the peer resends everything
// with a single pkmsg, avoiding the close-session cascade.
const retryRequestActiveJids = new Set<string>()
let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined
let lastTcTokenPruneTs = 0 let lastTcTokenPruneTs = 0
@@ -365,10 +375,14 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'NotificationNewsletterAdminPromote': case 'NotificationNewsletterAdminPromote':
for (const update of updates) { for (const update of updates) {
if (update.jid && update.user) { if (update.jid && update.user) {
const [resolvedAuthor, resolvedUser] = await Promise.all([
resolveLidToPn(node.attrs.from!, signalRepository.lidMapping, logger),
resolveLidToPn(update.user, signalRepository.lidMapping, logger)
])
ev.emit('newsletter-participants.update', { ev.emit('newsletter-participants.update', {
id: update.jid, id: update.jid,
author: node.attrs.from!, author: resolvedAuthor || node.attrs.from!,
user: update.user, user: resolvedUser || update.user,
new_role: 'ADMIN', new_role: 'ADMIN',
action: 'promote' action: 'promote'
}) })
@@ -387,7 +401,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNewsletterNotification = async (node: BinaryNode) => { const handleNewsletterNotification = async (node: BinaryNode) => {
const from = node.attrs.from! const from = node.attrs.from!
const child = getAllBinaryNodeChildren(node)[0]! const child = getAllBinaryNodeChildren(node)[0]!
const author = node.attrs.participant! const rawAuthor = node.attrs.participant!
// Resolve author LID→PN (participant is a user JID that could be LID)
const author = await resolveLidToPn(rawAuthor, signalRepository.lidMapping, logger) || rawAuthor
logger.info({ from, child }, 'got newsletter notification') logger.info({ from, child }, 'got newsletter notification')
@@ -414,10 +430,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
break break
case 'participant': case 'participant':
const resolvedParticipantUser = await resolveLidToPn(child.attrs.jid!, signalRepository.lidMapping, logger) || child.attrs.jid!
const participantUpdate = { const participantUpdate = {
id: from, id: from,
author, author,
user: child.attrs.jid!, user: resolvedParticipantUser,
action: child.attrs.action!, action: child.attrs.action!,
new_role: child.attrs.role! new_role: child.attrs.role!
} }
@@ -1200,12 +1217,50 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { key: msgKey } = fullMessage const { key: msgKey } = fullMessage
const msgId = msgKey.id! const msgId = msgKey.id!
// Per-JID deduplication: when multiple messages from the same contact
// fail with Bad MAC simultaneously, only send ONE retry request.
// The peer will resend all failed messages when it receives the retry receipt.
// For group messages, scope by participant (each participant has its own Signal session).
const retryDedupeJid = msgKey.participant
? jidNormalizedUser(msgKey.participant)
: jidNormalizedUser(node.attrs.from!)
if (retryRequestActiveJids.has(retryDedupeJid)) {
logger.debug(
{ fromJid: retryDedupeJid, msgId },
'Skipping duplicate retry request — already in-flight for this JID'
)
return
}
if (messageRetryManager) { if (messageRetryManager) {
// Check if we've exceeded max retries using the new system // Check if we've exceeded max retries using the new system
if (messageRetryManager.hasExceededMaxRetries(msgId)) { if (messageRetryManager.hasExceededMaxRetries(msgId)) {
logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing') logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing')
messageRetryManager.markRetryFailed(msgId) messageRetryManager.markRetryFailed(msgId)
recordMessageFailure('retry', 'max_retries_reached') recordMessageFailure('retry', 'max_retries_reached')
// Safety net: clean up corrupted sessions only after all retries exhausted.
// This avoids the cascading delete loop that occurs when cleanup runs
// on every Bad MAC in the hot path (decode-wa-message.ts).
// The Signal Protocol recovers naturally via retry+pkmsg for most cases;
// this cleanup only runs as a last resort.
// For group messages, use participant JID (Signal sessions are per-participant, not per-group).
if (autoCleanCorrupted) {
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
const deletedCount = await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
if (deletedCount > 0) {
logger.info(
{ msgId, jid: decryptionJid, targetedDevices: deletedCount },
`🔄 Session cleanup (retry exhausted) | Targeted: ${deletedCount} devices`
)
}
} catch (cleanupErr) {
logger.warn({ msgId, senderJid, err: cleanupErr }, 'Failed to cleanup session after retry exhaustion')
}
}
return return
} }
@@ -1224,6 +1279,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ retryCount, msgId }, 'reached retry limit, clearing') logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
await msgRetryCache.del(key) await msgRetryCache.del(key)
recordMessageFailure('retry', 'max_retries_reached') recordMessageFailure('retry', 'max_retries_reached')
// Safety net cleanup (same as new system above)
if (autoCleanCorrupted) {
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
} catch (cleanupErr) {
logger.warn({ msgId, senderJid, err: cleanupErr }, 'Failed to cleanup session after retry exhaustion')
}
}
return return
} }
@@ -1232,6 +1299,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
recordMessageRetry('retry') recordMessageRetry('retry')
} }
// Register dedup AFTER early-return checks so that max-retries paths
// don't block subsequent messages from the same JID for 5 seconds.
retryRequestActiveJids.add(retryDedupeJid)
setTimeout(() => retryRequestActiveJids.delete(retryDedupeJid), 5_000)
const key = `${msgId}:${msgKey?.participant}` const key = `${msgId}:${msgKey?.participant}`
const retryCount = (await msgRetryCache.get<number>(key)) || 1 const retryCount = (await msgRetryCache.get<number>(key)) || 1
@@ -1406,8 +1478,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
const handleGroupNotification = (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => { const handleGroupNotification = async (fullNode: BinaryNode, child: BinaryNode, msg: Partial<WAMessage>) => {
// TODO: Support PN/LID (Here is only LID now) const lidMapping = signalRepository.lidMapping
const actingParticipantLid = fullNode.attrs.participant const actingParticipantLid = fullNode.attrs.participant
const actingParticipantPn = fullNode.attrs.participant_pn const actingParticipantPn = fullNode.attrs.participant_pn
@@ -1415,13 +1487,52 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid! const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid!
const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn! const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn!
// Resolve acting participant to PN — prefer inline PN, fall back to LID→PN resolution
const actingParticipant = actingParticipantPn
|| await resolveLidToPn(actingParticipantLid, lidMapping, logger)
// Resolve affected participant to PN
const affectedParticipant = affectedParticipantPn
|| await resolveLidToPn(affectedParticipantLid, lidMapping, logger)
// Store LID↔PN mappings from notification attributes
const mappingsToStore: Array<{ lid: string; pn: string }> = []
if (actingParticipantLid && actingParticipantPn && isLidUser(actingParticipantLid) && isPnUser(actingParticipantPn)) {
mappingsToStore.push({ lid: actingParticipantLid, pn: actingParticipantPn })
}
switch (child?.tag) { switch (child?.tag) {
case 'create': case 'create':
const metadata = extractGroupMetadata(child) const metadata = extractGroupMetadata(child)
// Normalize group metadata participant IDs to PN
for (const p of metadata.participants) {
if (isLidUser(p.id)) {
// Use inline phoneNumber if available, otherwise resolve via mapping
if (p.phoneNumber) {
// Store the mapping
mappingsToStore.push({ lid: p.id, pn: 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
}
}
}
}
// Resolve metadata owner to PN
if (metadata.owner && isLidUser(metadata.owner)) {
const resolvedOwner = metadata.ownerPn || await resolveLidToPn(metadata.owner, lidMapping, logger)
if (resolvedOwner) metadata.owner = resolvedOwner
}
msg.messageStubType = WAMessageStubType.GROUP_CREATE msg.messageStubType = WAMessageStubType.GROUP_CREATE
msg.messageStubParameters = [metadata.subject] msg.messageStubParameters = [metadata.subject]
msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn } msg.key = { participant: actingParticipant }
ev.emit('chats.upsert', [ ev.emit('chats.upsert', [
{ {
@@ -1433,8 +1544,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
ev.emit('groups.upsert', [ ev.emit('groups.upsert', [
{ {
...metadata, ...metadata,
author: actingParticipantLid, author: actingParticipant
authorPn: actingParticipantPn
} }
]) ])
break break
@@ -1448,8 +1558,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
break break
case 'modify': case 'modify':
const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid!) const oldNumbers = await Promise.all(
msg.messageStubParameters = oldNumber || [] getBinaryNodeChildren(child, 'participant').map(async p => {
const resolved = await resolveLidToPn(p.attrs.jid!, lidMapping, logger)
return resolved || p.attrs.jid!
})
)
msg.messageStubParameters = oldNumbers || []
msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER
break break
case 'promote': case 'promote':
@@ -1460,21 +1575,48 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}` const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`
msg.messageStubType = WAMessageStubType[stubType as keyof typeof WAMessageStubType] msg.messageStubType = WAMessageStubType[stubType as keyof typeof WAMessageStubType]
const participants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => { const participants = await Promise.all(
// TODO: Store LID MAPPINGS getBinaryNodeChildren(child, 'participant').map(async ({ attrs }) => {
return { const rawJid = attrs.jid!
id: attrs.jid!, let id = rawJid
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, let phoneNumber: string | undefined
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined, let lid: string | undefined
admin: (attrs.type || null) as GroupParticipant['admin']
} if (isLidUser(rawJid)) {
}) // Primary is LID — resolve to PN
phoneNumber = isPnUser(attrs.phone_number) ? attrs.phone_number : undefined
if (phoneNumber) {
mappingsToStore.push({ lid: rawJid, pn: phoneNumber })
lid = rawJid
id = phoneNumber
} else {
const resolved = await resolveLidToPn(rawJid, lidMapping, logger)
if (resolved && resolved !== rawJid) {
lid = rawJid
id = resolved
}
}
} else if (isPnUser(rawJid) && isLidUser(attrs.lid)) {
// Primary is PN — store LID for reference
lid = attrs.lid
mappingsToStore.push({ lid: attrs.lid!, pn: rawJid })
}
return {
id,
phoneNumber,
lid,
admin: (attrs.type || null) as GroupParticipant['admin']
}
})
)
if ( if (
participants.length === 1 && participants.length === 1 &&
// if recv. "remove" message and sender removed themselves // if recv. "remove" message and sender removed themselves
// mark as left // mark as left
(areJidsSameUser(participants[0]!.id, actingParticipantLid) || (areJidsSameUser(participants[0]!.id, actingParticipant) ||
areJidsSameUser(participants[0]!.id, actingParticipantLid) ||
areJidsSameUser(participants[0]!.id, actingParticipantPn)) && areJidsSameUser(participants[0]!.id, actingParticipantPn)) &&
child.tag === 'remove' child.tag === 'remove'
) { ) {
@@ -1525,21 +1667,27 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'created_membership_requests': case 'created_membership_requests':
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [ msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
'created', 'created',
child.attrs.request_method! child.attrs.request_method!
] ]
break break
case 'revoked_membership_requests': case 'revoked_membership_requests':
const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid) const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid)
// TODO: LIDMAPPING SUPPORT
msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
msg.messageStubParameters = [ msg.messageStubParameters = [
JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipant }),
isDenied ? 'revoked' : 'rejected' isDenied ? 'revoked' : 'rejected'
] ]
break break
} }
// Persist any LID↔PN mappings discovered from notification attributes
if (mappingsToStore.length) {
await lidMapping.storeLIDPNMappings(mappingsToStore).catch(err => {
logger.warn({ err, count: mappingsToStore.length }, 'Failed to store LID↔PN mappings from group notification')
})
}
} }
const processNotification = async (node: BinaryNode) => { const processNotification = async (node: BinaryNode) => {
@@ -1556,11 +1704,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await handleMexNewsletterNotification(node) await handleMexNewsletterNotification(node)
break break
case 'w:gp2': case 'w:gp2':
// TODO: HANDLE PARTICIPANT_PN await handleGroupNotification(node, child!, result)
handleGroupNotification(node, child!, result)
break break
case 'mediaretry': case 'mediaretry':
const event = decodeMediaRetryNode(node) const event = decodeMediaRetryNode(node)
// Normalize LID→PN in media retry key before emitting
await normalizeKeyLidToPn(event.key, signalRepository.lidMapping, logger)
ev.emit('messages.media-update', [event]) ev.emit('messages.media-update', [event])
break break
case 'encrypt': case 'encrypt':
@@ -1591,13 +1740,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const setPicture = getBinaryNodeChild(node, 'set') const setPicture = getBinaryNodeChild(node, 'set')
const delPicture = getBinaryNodeChild(node, 'delete') const delPicture = getBinaryNodeChild(node, 'delete')
// TODO: WAJIDHASH stuff proper support inhouse {
ev.emit('contacts.update', [ const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || ''
{ const pictureJid = await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger) || rawPictureJid
id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', ev.emit('contacts.update', [
imgUrl: setPicture ? 'changed' : 'removed' {
} id: pictureJid,
]) imgUrl: setPicture ? 'changed' : 'removed'
}
])
}
if (isJidGroup(from)) { if (isJidGroup(from)) {
const node = setPicture || delPicture const node = setPicture || delPicture
@@ -1635,7 +1787,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const blocklists = getBinaryNodeChildren(child, 'item') const blocklists = getBinaryNodeChildren(child, 'item')
for (const { attrs } of blocklists) { for (const { attrs } of blocklists) {
const blocklist = [attrs.jid!] const resolvedBlockJid = await resolveLidToPn(attrs.jid!, signalRepository.lidMapping, logger) || attrs.jid!
const blocklist = [resolvedBlockJid]
const type = attrs.action === 'block' ? 'add' : 'remove' const type = attrs.action === 'block' ? 'add' : 'remove'
ev.emit('blocklist.update', { blocklist, type }) ev.emit('blocklist.update', { blocklist, type })
} }
@@ -1927,6 +2080,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
participant: attrs.participant participant: attrs.participant
} }
// Normalize LID→PN in receipt key so events always emit PN JIDs
const lidMapping = signalRepository.lidMapping
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
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignoring receipt from jid') logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node) await sendMessageAck(node)
@@ -1953,12 +2115,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (attrs.participant) { if (attrs.participant) {
const updateKey: keyof MessageUserReceipt = const updateKey: keyof MessageUserReceipt =
status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp' status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
const resolvedReceiptUserJid = await resolveLidToPn(attrs.participant, lidMapping, logger) || jidNormalizedUser(attrs.participant)
ev.emit( ev.emit(
'message-receipt.update', 'message-receipt.update',
ids.map(id => ({ ids.map(id => ({
key: { ...key, id }, key: { ...key, id },
receipt: { receipt: {
userJid: jidNormalizedUser(attrs.participant), userJid: jidNormalizedUser(resolvedReceiptUserJid),
[updateKey]: +attrs.t! [updateKey]: +attrs.t!
} }
})) }))
@@ -2071,7 +2234,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
authState.creds.me!.lid || '', authState.creds.me!.lid || '',
signalRepository, signalRepository,
logger, logger,
autoCleanCorrupted
) )
const alt = msg.key.participantAlt || msg.key.remoteJidAlt const alt = msg.key.participantAlt || msg.key.remoteJidAlt
@@ -2087,10 +2249,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if mapping already exists to avoid unnecessary storage operations // Check if mapping already exists to avoid unnecessary storage operations
const existingMapping = await signalRepository.lidMapping.getPNForLID(alt) const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
if (!existingMapping) { if (!existingMapping) {
// Store mapping in background (non-critical, doesn't block decrypt) // MUST await: normalizeMessageJids() runs after this and needs the mapping
signalRepository.lidMapping // in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]) .storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed')) .catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
} }
// CRITICAL: ALWAYS migrate session, even if mapping exists // CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2103,10 +2266,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Check if reverse mapping exists // Check if reverse mapping exists
const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt) const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
if (!existingMapping) { if (!existingMapping) {
// Store mapping in background (non-critical) // MUST await: normalizeMessageJids() runs after this and needs the mapping
signalRepository.lidMapping // in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]) .storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed')) .catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
} }
// CRITICAL: ALWAYS migrate session, even if mapping exists // CRITICAL: ALWAYS migrate session, even if mapping exists
@@ -2607,6 +2771,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await callOfferCache.del(call.id) await callOfferCache.del(call.id)
} }
// Normalize LID→PN in call event JIDs before emitting to consumers
const callLidMapping = signalRepository.lidMapping
const [resolvedChatId, resolvedFrom, resolvedLinkCreator] = await Promise.all([
resolveLidToPn(call.chatId, callLidMapping, logger),
resolveLidToPn(call.from, callLidMapping, logger),
resolveLidToPn(call.linkCreator, callLidMapping, logger)
])
if (resolvedChatId) call.chatId = resolvedChatId
if (resolvedFrom) call.from = resolvedFrom
if (resolvedLinkCreator) call.linkCreator = resolvedLinkCreator
// Resolve participant JIDs in parallel
if (call.participants) {
await Promise.all(call.participants.map(async (p) => {
if (p.jid) {
const resolved = p.userPn || await resolveLidToPn(p.jid, callLidMapping, logger)
if (resolved) p.jid = resolved
}
}))
}
ev.emit('call', [call]) ev.emit('call', [call])
} }
@@ -2615,6 +2799,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleBadAck = async ({ attrs }: BinaryNode) => { const handleBadAck = async ({ attrs }: BinaryNode) => {
const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id } const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id }
await normalizeKeyLidToPn(key, signalRepository.lidMapping, logger)
// WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP // WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP
// // current hypothesis is that if pash is sent in the ack // // current hypothesis is that if pash is sent in the ack
@@ -2802,10 +2987,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Top-level <relay> stanzas carry TURN server info, tokens and crypto keys // Top-level <relay> stanzas carry TURN server info, tokens and crypto keys
ws.on('CB:relay', async (node: BinaryNode) => { ws.on('CB:relay', async (node: BinaryNode) => {
const callId = node.attrs['call-id'] const callId = node.attrs['call-id']
const callCreator = node.attrs['call-creator'] const rawCallCreator = node.attrs['call-creator']
// Both callId and callCreator must be present to emit a valid event // Both callId and callCreator must be present to emit a valid event
// (call link relays may arrive without these attrs — just log them) // (call link relays may arrive without these attrs — just log them)
if (callId && callCreator) { if (callId && rawCallCreator) {
// Resolve LID→PN for call creator
const callCreator = await resolveLidToPn(rawCallCreator, signalRepository.lidMapping, logger) || rawCallCreator
logger.debug( logger.debug(
{ callId, callCreator, uuid: node.attrs.uuid }, { callId, callCreator, uuid: node.attrs.uuid },
'received relay info' 'received relay info'
+16 -6
View File
@@ -38,7 +38,7 @@ import {
signedKeyPair, signedKeyPair,
xmppSignedPreKey xmppSignedPreKey
} from '../Utils' } from '../Utils'
import { getPlatformId } from '../Utils/browser-utils' import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils'
import { import {
CircuitBreaker, CircuitBreaker,
CircuitOpenError, CircuitOpenError,
@@ -634,8 +634,6 @@ export const makeSocket = (config: SocketConfig) => {
} }
helloMsg = proto.HandshakeMessage.fromObject(helloMsg) helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish() const init = proto.HandshakeMessage.encode(helloMsg).finish()
const result = await awaitNextMessage<Uint8Array>(init) const result = await awaitNextMessage<Uint8Array>(init)
@@ -1352,6 +1350,16 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'), id: jidEncode(phoneNumber, 's.whatsapp.net'),
name: '~' name: '~'
} }
// Pair code with Android browser doesn't work (WA server rejects the
// companion registration). When Android is detected, fall back to the
// Chrome/macOS platform values that are known to work.
const isAndroid = isAndroidBrowser(browser)
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
logger.info(`\uD83D\uDD17 Pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override \u2192 Chrome' : 'native platform'}`)
ev.emit('creds.update', authState.creds) ev.emit('creds.update', authState.creds)
await sendNode({ await sendNode({
tag: 'iq', tag: 'iq',
@@ -1384,12 +1392,12 @@ export const makeSocket = (config: SocketConfig) => {
{ {
tag: 'companion_platform_id', tag: 'companion_platform_id',
attrs: {}, attrs: {},
content: getPlatformId(browser[1]) content: pairPlatformId
}, },
{ {
tag: 'companion_platform_display', tag: 'companion_platform_display',
attrs: {}, attrs: {},
content: `${browser[1]} (${browser[0]})` content: pairPlatformDisplay
}, },
{ {
tag: 'link_code_pairing_nonce', tag: 'link_code_pairing_nonce',
@@ -1519,7 +1527,9 @@ export const makeSocket = (config: SocketConfig) => {
}) })
// login complete // login complete
ws.on('CB:success', async (node: BinaryNode) => { ws.on('CB:success', async (node: BinaryNode) => {
logger.info('opened connection to WA') const isAndroid = isAndroidBrowser(browser)
const phoneId = authState.creds.me?.id?.split(':')[0]?.split('@')[0] || 'new session'
logger.info(`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`)
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } }) ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
+1
View File
@@ -23,6 +23,7 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string] baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string] windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string] appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
} }
export enum DisconnectReason { export enum DisconnectReason {
+20 -3
View File
@@ -94,7 +94,13 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
} }
} }
return new Map(entries) const map = new Map(entries)
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
map.set('ANDROID', map.get('ANDROID_PHONE')!)
}
return map
})() })()
// ============================================================ // ============================================================
@@ -331,7 +337,8 @@ export const Browsers: BrowsersMap = {
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS], macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows], windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys], baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()] appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()],
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
} as const } as const
/** /**
@@ -378,7 +385,7 @@ export const getPlatformId = (browser: unknown): string => {
* properties like 'toString' or 'constructor'. * properties like 'toString' or 'constructor'.
* *
* @param value - Value to check * @param value - Value to check
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate') * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android')
* *
* @example * @example
* isValidBrowserPreset('ubuntu') // true * isValidBrowserPreset('ubuntu') // true
@@ -391,6 +398,16 @@ export const getPlatformId = (browser: unknown): string => {
* const config = Browsers[userInput]('MyApp') * const config = Browsers[userInput]('MyApp')
* } * }
*/ */
/**
* Checks if the browser tuple represents an Android companion device.
*
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
}
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value) return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
} }
+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
+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
+93 -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,
@@ -125,42 +128,94 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
} }
} }
/**
* 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 LIDPN 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)
} }
} }
@@ -534,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', {
@@ -674,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]
} }
@@ -719,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:
+14 -5
View File
@@ -9,24 +9,27 @@ import {
} from '../Defaults' } from '../Defaults'
import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types' import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types'
import { type BinaryNode, getBinaryNodeChild, jidDecode, S_WHATSAPP_NET } from '../WABinary' import { type BinaryNode, getBinaryNodeChild, jidDecode, S_WHATSAPP_NET } from '../WABinary'
import { isAndroidBrowser } from './browser-utils'
import { Curve, hmacSign } from './crypto' import { Curve, hmacSign } from './crypto'
import { encodeBigEndian } from './generics' import { encodeBigEndian } from './generics'
import { createSignalIdentity } from './signal' import { createSignalIdentity } from './signal'
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
const isAndroid = isAndroidBrowser(config.browser)
return { return {
appVersion: { appVersion: {
primary: config.version[0], primary: config.version[0],
secondary: config.version[1], secondary: config.version[1],
tertiary: config.version[2] tertiary: config.version[2]
}, },
platform: proto.ClientPayload.UserAgent.Platform.MACOS, platform: isAndroid
? proto.ClientPayload.UserAgent.Platform.SMB_ANDROID
: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1', osVersion: isAndroid ? config.browser[0] : '0.1',
device: 'Desktop', device: isAndroid ? 'Android' : 'Desktop',
osBuildNumber: '0.1', osBuildNumber: '0.1',
localeLanguageIso6391: 'en', localeLanguageIso6391: 'en',
mnc: '000', mnc: '000',
mcc: '000', mcc: '000',
localeCountryIso31661Alpha2: config.countryCode localeCountryIso31661Alpha2: config.countryCode
@@ -58,7 +61,9 @@ const getClientPayload = (config: SocketConfig) => {
userAgent: getUserAgent(config) userAgent: getUserAgent(config)
} }
payload.webInfo = getWebInfo(config) if (!isAndroidBrowser(config.browser)) {
payload.webInfo = getWebInfo(config)
}
return payload return payload
} }
@@ -84,6 +89,10 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
const platformType = platform.toUpperCase() const platformType = platform.toUpperCase()
if (platformType === 'ANDROID') {
return proto.DeviceProps.PlatformType.ANDROID_PHONE
}
return ( return (
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
proto.DeviceProps.PlatformType.CHROME proto.DeviceProps.PlatformType.CHROME