Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27b942f03c | |||
| 0eae108ba4 | |||
| cb80febba5 | |||
| 31ac5aeb11 | |||
| 45885c7a01 | |||
| f1b43e26a1 | |||
| a3dd21c9d7 | |||
| e1e3d88a1c |
@@ -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 @@
|
|||||||
{"version":[2,3000,1034279434]}
|
{"version":[2,3000,1034300341]}
|
||||||
|
|||||||
+24
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+222
-35
@@ -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 }) => {
|
||||||
|
const rawJid = attrs.jid!
|
||||||
|
let id = rawJid
|
||||||
|
let phoneNumber: string | undefined
|
||||||
|
let lid: string | undefined
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
id: attrs.jid!,
|
id,
|
||||||
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
|
phoneNumber,
|
||||||
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
|
lid,
|
||||||
admin: (attrs.type || null) as GroupParticipant['admin']
|
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
|
{
|
||||||
|
const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || ''
|
||||||
|
const pictureJid = await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger) || rawPictureJid
|
||||||
ev.emit('contacts.update', [
|
ev.emit('contacts.update', [
|
||||||
{
|
{
|
||||||
id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '',
|
id: pictureJid,
|
||||||
imgUrl: setPicture ? 'changed' : 'removed'
|
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
@@ -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 } })
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const normalizeMessageJids = async (
|
/**
|
||||||
message: WAMessage,
|
* Resolves a LID JID to its PN equivalent using the LID mapping store.
|
||||||
signalRepository: SignalRepositoryWithLIDStore,
|
* 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
|
logger?: ILogger
|
||||||
): Promise<void> => {
|
): Promise<string | undefined> => {
|
||||||
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
|
|
||||||
if (!jid) {
|
if (!jid) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLidUser(jid) || isHostedLidUser(jid)) {
|
if (isAnyLidUser(jid)) {
|
||||||
const pn = await signalRepository.lidMapping.getPNForLID(jid)
|
const pn = await lidMapping.getPNForLID(jid)
|
||||||
if (pn) {
|
if (pn) {
|
||||||
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message')
|
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
|
||||||
} else {
|
|
||||||
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pn || jid
|
return pn || jid
|
||||||
}
|
}
|
||||||
|
|
||||||
return jid
|
return jid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute both lookups in parallel instead of sequentially to reduce latency
|
/**
|
||||||
|
* 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([
|
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
|
||||||
resolveLidToPn(message.key.remoteJid),
|
resolveLidToPn(key.remoteJid, lidMapping, logger),
|
||||||
resolveLidToPn(message.key.participant)
|
resolveLidToPn(key.participant, lidMapping, logger)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (resolvedRemoteJid) {
|
if (resolvedRemoteJid) {
|
||||||
message.key.remoteJid = resolvedRemoteJid
|
key.remoteJid = resolvedRemoteJid
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedParticipant) {
|
if (resolvedParticipant) {
|
||||||
message.key.participant = resolvedParticipant
|
key.participant = resolvedParticipant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeMessageJids = async (
|
||||||
|
message: WAMessage,
|
||||||
|
signalRepository: SignalRepositoryWithLIDStore,
|
||||||
|
logger?: ILogger
|
||||||
|
): Promise<void> => {
|
||||||
|
const lidMapping = signalRepository.lidMapping
|
||||||
|
const key = message.key
|
||||||
|
|
||||||
|
// FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
|
||||||
|
// The stanza always carries both formats (LID + PN) in the attributes.
|
||||||
|
// When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
|
||||||
|
// When addressing_mode=pn, remoteJid is already PN (no conversion needed).
|
||||||
|
if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
|
||||||
|
logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
|
||||||
|
key.remoteJid = key.remoteJidAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
|
||||||
|
logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
|
||||||
|
key.participant = key.participantAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAndroidBrowser(config.browser)) {
|
||||||
payload.webInfo = getWebInfo(config)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user