style: auto-fix lint errors and formatting issues

Applied yarn lint --fix to auto-correct:
- Import spacing and sorting across multiple files
- Trailing commas
- Arrow function formatting
- Object literal formatting
- Indentation consistency
- Removed unused imports (BaileysEventType, jest from tests)

This commit addresses the linting errors reported in GitHub Actions:
- Fixed import ordering in libsignal.ts
- Fixed trailing commas in Defaults/index.ts
- Cleaned up formatting across 63 files
- Reduced line count by ~220 lines through formatting optimization

Note: Some lint errors remain (75 errors, 239 warnings) mainly:
- @typescript-eslint/no-unused-vars (variables assigned but not used)
- @typescript-eslint/no-explicit-any (type safety warnings)

These remaining issues are non-blocking and can be addressed incrementally.

https://claude.ai/code/session_015R3U3kiprQiNTTNNt31Sg6
This commit is contained in:
Claude
2026-02-13 21:59:35 +00:00
parent ce98b240ca
commit 4b02652369
63 changed files with 1677 additions and 1897 deletions
+1
View File
@@ -19,6 +19,7 @@ export abstract class AbstractSocketClient extends EventEmitter {
if (maxListeners === 0) {
this.config.logger?.warn('SocketClient setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.setMaxListeners(maxListeners)
}
+1
View File
@@ -37,6 +37,7 @@ export class WebSocketClient extends AbstractSocketClient {
if (maxListeners === 0) {
this.config.logger?.warn('WebSocket setMaxListeners(0) allows UNLIMITED listeners - potential memory leak!')
}
this.socket.setMaxListeners(maxListeners)
const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response']
+21 -16
View File
@@ -2,7 +2,7 @@ import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { LRUCache } from 'lru-cache'
import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_TTLS, DEFAULT_CACHE_MAX_KEYS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import type {
BotListInfo,
CacheStore,
@@ -43,7 +43,7 @@ import {
newLTHashState,
processSyncAction
} from '../Utils'
import { makeMutex, makeKeyedMutex } from '../Utils/make-mutex'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message'
import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
import {
@@ -70,7 +70,17 @@ export const makeChatsSocket = (config: SocketConfig) => {
getMessage
} = config
const sock = makeSocket(config)
const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError, sendUnifiedSession } = sock
const {
ev,
ws,
authState,
generateMessageTag,
sendNode,
query,
signalRepository,
onUnexpectedError,
sendUnifiedSession
} = sock
let privacySettings: { [_: string]: string } | undefined
@@ -641,9 +651,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
// or key not found
const isKeyNotFound = error.output?.statusCode === 404
const isIrrecoverableError =
(attemptsMap[name] || 0) >= MAX_SYNC_ATTEMPTS ||
isKeyNotFound ||
error.name === 'TypeError'
(attemptsMap[name] || 0) >= MAX_SYNC_ATTEMPTS || isKeyNotFound || error.name === 'TypeError'
if (isKeyNotFound) {
const currentVersion = states[name]?.version ?? 0
logger.info(
@@ -656,6 +664,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
`failed to sync state from version${isIrrecoverableError ? '' : ', removing and trying from scratch'}`
)
}
await authState.keys.set({ 'app-state-sync-version': { [name]: null } })
// increment number of retries
attemptsMap[name] = (attemptsMap[name] || 0) + 1
@@ -765,13 +774,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
logger.warn({ toJid }, 'sendPresenceUpdate: failed to decode toJid, skipping')
return
}
const { server } = decoded
const isLid = server === 'lid'
await sendNode({
tag: 'chatstate',
attrs: {
from: isLid ? (me.lid || me.id) : me.id,
from: isLid ? me.lid || me.id : me.id,
to: toJid
},
content: [
@@ -826,6 +836,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return
}
let type = firstChild.tag as WAPresence
if (type === 'paused') {
type = 'available'
@@ -1294,7 +1305,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}, 20_000)
})
ev.on('lid-mapping.update', async (mappings) => {
ev.on('lid-mapping.update', async mappings => {
try {
const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings)
logger.debug(
@@ -1319,10 +1330,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
const pnUser = jidNormalizedUser(mapping.pn)
if (lidUser && pnUser && lidUser !== pnUser) {
logger.debug(
{ lid: lidUser, pn: pnUser },
'collected chat update for LID→PN merge notification'
)
logger.debug({ lid: lidUser, pn: pnUser }, 'collected chat update for LID→PN merge notification')
mergeNotifications.push({
id: pnUser,
@@ -1335,10 +1343,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
// Emit single batch of merge notifications for better performance
if (mergeNotifications.length > 0) {
logger.debug(
{ count: mergeNotifications.length },
'emitting batch of chat merge notifications'
)
logger.debug({ count: mergeNotifications.length }, 'emitting batch of chat merge notifications')
ev.emit('chats.update', mergeNotifications)
}
+6 -17
View File
@@ -1,7 +1,7 @@
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import type { UserFacingSocketConfig, WAVersion } from '../Types'
import { getCachedVersion, refreshVersionCache, clearVersionCache, getVersionCacheStatus } from '../Utils/version-cache'
import type { VersionCacheLogger } from '../Utils/version-cache'
import { clearVersionCache, getCachedVersion, getVersionCacheStatus, refreshVersionCache } from '../Utils/version-cache'
import { makeCommunitiesSocket } from './communities'
/**
@@ -109,18 +109,13 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) =>
fromCache,
ageMinutes: fromCache ? Math.round(age / 60000) : 0
},
fromCache
? 'Using cached WhatsApp Web version'
: 'Fetched fresh WhatsApp Web version'
fromCache ? 'Using cached WhatsApp Web version' : 'Fetched fresh WhatsApp Web version'
)
mergedConfig.version = version
trackedVersion = [...version] as WAVersion
} catch (error) {
logger?.warn(
{ error, fallbackVersion: mergedConfig.version },
'Error fetching version, using bundled version'
)
logger?.warn({ error, fallbackVersion: mergedConfig.version }, 'Error fetching version, using bundled version')
}
// Create the socket
@@ -128,7 +123,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) =>
// Listen for connection close to cleanup interval (Fix #1, #6)
// This handles both explicit sock.end() and internal disconnections
sock.ev.on('connection.update', (update) => {
sock.ev.on('connection.update', update => {
if (update.connection === 'close') {
isSocketClosed = true
cleanupInterval()
@@ -139,10 +134,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) =>
// Setup periodic version check if interval > 0
if (checkIntervalMs > 0) {
logger?.info(
{ intervalHours: checkIntervalMs / (60 * 60 * 1000) },
'Starting periodic version check'
)
logger?.info({ intervalHours: checkIntervalMs / (60 * 60 * 1000) }, 'Starting periodic version check')
versionCheckInterval = setInterval(async () => {
// Skip if socket is closed (Fix #8 - race condition)
@@ -169,10 +161,7 @@ export const makeWASocketAutoVersion = async (config: UserFacingSocketConfig) =>
// Don't update to fallback version on transient network errors
if (!fetchSuccess) {
logger?.warn(
{ fallbackVersion: result.version },
'Failed to fetch latest version, keeping current version'
)
logger?.warn({ fallbackVersion: result.version }, 'Failed to fetch latest version, keeping current version')
return // Skip version update on fetch failure
}
} else if (cacheStatus.version) {
+66 -49
View File
@@ -3,8 +3,14 @@ import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto'
import Long from 'long'
import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_TTLS, DEFAULT_SESSION_CLEANUP_CONFIG, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults'
import { metrics, recordMessageReceived, recordHistorySyncMessages, recordMessageRetry, recordMessageFailure } from '../Utils/prometheus-metrics.js'
import {
DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG,
KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS,
STATUS_EXPIRY_SECONDS
} from '../Defaults'
import type {
GroupParticipant,
MessageReceiptType,
@@ -18,12 +24,10 @@ import type {
WAPatchName
} from '../Types'
import { WAMessageStatus, WAMessageStubType } from '../Types'
import { logMessageReceived } from '../Utils/baileys-logger'
import {
aesDecryptCTR,
aesEncryptGCM,
cleanMessage,
normalizeMessageJids,
Curve,
decodeMediaRetryNode,
decodeMessageNode,
@@ -42,12 +46,21 @@ import {
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
normalizeMessageJids,
toNumber,
unixTimestampSeconds,
xmppPreKey,
xmppSignedPreKey
} from '../Utils'
import { logMessageReceived } from '../Utils/baileys-logger'
import { makeMutex } from '../Utils/make-mutex'
import {
metrics,
recordHistorySyncMessages,
recordMessageFailure,
recordMessageReceived,
recordMessageRetry
} from '../Utils/prometheus-metrics.js'
import {
areJidsSameUser,
type BinaryNode,
@@ -81,7 +94,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
sessionCleanupConfig
} = config
// Use nullish coalescing to handle partial config properly
const autoCleanCorrupted = sessionCleanupConfig?.autoCleanCorrupted ?? DEFAULT_SESSION_CLEANUP_CONFIG.autoCleanCorrupted
const autoCleanCorrupted =
sessionCleanupConfig?.autoCleanCorrupted ?? DEFAULT_SESSION_CLEANUP_CONFIG.autoCleanCorrupted
const sock = makeMessagesSocket(config)
const {
ev,
@@ -488,7 +502,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Extract error code from retry node if present (for MAC error detection)
const retryNode = getBinaryNodeChild(node, 'retry')
const errorAttr = retryNode?.attrs?.error as string | undefined
const errorAttr = retryNode?.attrs?.error
const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr)
const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode)
@@ -1060,7 +1074,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
recreateReason = result.reason
if (shouldRecreateSession) {
logger.debug({ participant, retryCount, reason: recreateReason, errorCode }, 'recreating session for outgoing retry')
logger.debug(
{ participant, retryCount, reason: recreateReason, errorCode },
'recreating session for outgoing retry'
)
// CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race
// Using meId ensures this delete serializes with sendMessage() and other session operations
await authState.keys.transaction(async () => {
@@ -1255,7 +1272,14 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
category,
author,
decrypt
} = decryptMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '', signalRepository, logger, autoCleanCorrupted)
} = decryptMessageNode(
node,
authState.creds.me!.id,
authState.creds.me!.lid || '',
signalRepository,
logger,
autoCleanCorrupted
)
const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// Handle LID/PN mappings with hybrid approach:
@@ -1271,7 +1295,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
if (!existingMapping) {
// Store mapping in background (non-critical, doesn't block decrypt)
signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed'))
}
@@ -1286,7 +1311,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
if (!existingMapping) {
// Store mapping in background (non-critical)
signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'Background LID mapping storage failed'))
}
@@ -1320,7 +1346,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Fallback chain: remoteJid (normalized) > msg.key.id (unique) > 'unknown' (serializes all)
let mutexKey = msg.key.remoteJid
if (!mutexKey) {
logger.warn({ msgId: msg.key.id, fromMe: msg.key.fromMe }, 'Missing remoteJid after normalization, using msg.key.id as fallback')
logger.warn(
{ msgId: msg.key.id, fromMe: msg.key.fromMe },
'Missing remoteJid after normalization, using msg.key.id as fallback'
)
mutexKey = msg.key.id || 'unknown'
}
@@ -1341,9 +1370,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Skip unavailable fanout types - these messages will never have content available
// These are system messages that cannot be decrypted or retrieved
const messageType = msg.messageStubParameters?.[2]
if (messageType === 'bot_unavailable_fanout' ||
if (
messageType === 'bot_unavailable_fanout' ||
messageType === 'hosted_unavailable_fanout' ||
messageType === 'view_once_unavailable_fanout') {
messageType === 'view_once_unavailable_fanout'
) {
logger.debug(
{ msgId: msg.key?.id, messageType },
'CTWA: Skipping placeholder resend for unavailable fanout type'
@@ -1395,34 +1426,22 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
const requestId = await requestPlaceholderResend(msgKey, msgData)
if (requestId && requestId !== 'RESOLVED') {
logger.debug(
{ msgId, requestId },
'CTWA: Placeholder resend request sent successfully'
)
logger.debug({ msgId, requestId }, 'CTWA: Placeholder resend request sent successfully')
metrics.ctwaRecoveryRequests.inc({ status: 'sent' })
// Note: The actual message will be emitted via 'messages.upsert'
// when the PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE is processed
// in the PDO response handler in src/Utils/process-message.ts
} else if (requestId === 'RESOLVED') {
// Message was received while we were waiting
logger.debug(
{ msgId },
'CTWA: Message received during resend delay'
)
logger.debug({ msgId }, 'CTWA: Message received during resend delay')
metrics.ctwaMessagesRecovered.inc()
metrics.ctwaRecoveryLatency.observe(Date.now() - startTime)
} else {
// Already requested (duplicate request prevented by cache)
logger.debug(
{ msgId },
'CTWA: Resend already requested, skipping duplicate'
)
logger.debug({ msgId }, 'CTWA: Resend already requested, skipping duplicate')
}
} catch (error) {
logger.warn(
{ error, msgId },
'CTWA: Failed to request placeholder resend'
)
logger.warn({ error, msgId }, 'CTWA: Failed to request placeholder resend')
metrics.ctwaRecoveryFailures.inc({ reason: 'request_failed' })
}
})
@@ -1433,24 +1452,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
const requestId = await requestPlaceholderResend(msgKey, msgData)
if (requestId && requestId !== 'RESOLVED') {
logger.debug(
{ msgId, requestId },
'CTWA: Placeholder resend request sent successfully (direct)'
)
logger.debug({ msgId, requestId }, 'CTWA: Placeholder resend request sent successfully (direct)')
} else if (requestId === 'RESOLVED') {
// Message arrived during the internal 2s delay in requestPlaceholderResend
logger.debug(
{ msgId },
'CTWA: Message received before direct resend request completed'
)
logger.debug({ msgId }, 'CTWA: Message received before direct resend request completed')
metrics.ctwaMessagesRecovered.inc()
metrics.ctwaRecoveryLatency.observe(Date.now() - startTime)
} else {
// Already requested (duplicate request prevented by cache)
logger.debug(
{ msgId },
'CTWA: Resend already requested, skipping duplicate (direct)'
)
logger.debug({ msgId }, 'CTWA: Resend already requested, skipping duplicate (direct)')
}
} catch (error) {
logger.warn({ error, msgId }, 'CTWA: Failed to request placeholder resend')
@@ -1575,14 +1585,21 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Record message received metric
const msgContent = msg.message
const msgType = msgContent?.conversation ? 'text'
: msgContent?.imageMessage ? 'image'
: msgContent?.videoMessage ? 'video'
: msgContent?.audioMessage ? 'audio'
: msgContent?.documentMessage ? 'document'
: msgContent?.stickerMessage ? 'sticker'
: msgContent?.reactionMessage ? 'reaction'
: 'other'
const msgType = msgContent?.conversation
? 'text'
: msgContent?.imageMessage
? 'image'
: msgContent?.videoMessage
? 'video'
: msgContent?.audioMessage
? 'audio'
: msgContent?.documentMessage
? 'document'
: msgContent?.stickerMessage
? 'sticker'
: msgContent?.reactionMessage
? 'reaction'
: 'other'
recordMessageReceived(msgType)
// Track session activity for cleanup
+105 -97
View File
@@ -16,7 +16,6 @@ import type {
WAMessage,
WAMessageKey
} from '../Types'
import { logMessageSent } from '../Utils/baileys-logger'
import {
aggregateMessageKeysNotFromMe,
assertMediaContent,
@@ -40,8 +39,10 @@ import {
parseAndInjectE2ESessions,
unixTimestampSeconds
} from '../Utils'
import { logMessageSent } from '../Utils/baileys-logger'
import { getUrlInfo } from '../Utils/link-preview'
import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageRetry, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
areJidsSameUser,
@@ -65,7 +66,6 @@ import {
S_WHATSAPP_NET
} from '../WABinary'
import { USyncQuery, USyncUser } from '../WAUSync'
import { recordMessageSent, recordMessageRetry, recordMessageFailure, metrics } from '../Utils/prometheus-metrics'
import { makeNewsletterSocket } from './newsletter'
export const makeMessagesSocket = (config: SocketConfig) => {
@@ -331,12 +331,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || ''
const extracted = extractDeviceJids(
result?.list,
meId,
meLid,
ignoreZeroDevices
)
const extracted = extractDeviceJids(result?.list, meId, meLid, ignoreZeroDevices)
const deviceMap: { [_: string]: FullJid[] } = {}
for (const item of extracted) {
@@ -465,9 +460,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const wireJids = [
...jidsRequiringFetch.filter(jid => isAnyLidUser(jid)),
...(
(await signalRepository.lidMapping.getLIDsForPNs(
jidsRequiringFetch.filter(jid => isAnyPnUser(jid))
)) || []
(await signalRepository.lidMapping.getLIDsForPNs(jidsRequiringFetch.filter(jid => isAnyPnUser(jid)))) || []
).map(a => a.lid)
]
@@ -650,6 +643,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (message.interactiveMessage.nativeFlowMessage) {
return 'native_flow'
}
// Check if it's a carousel with nativeFlowMessage buttons in cards
if (message.interactiveMessage.carouselMessage?.cards?.length) {
const hasNativeFlowButtons = message.interactiveMessage.carouselMessage.cards.some(
@@ -659,6 +653,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return 'native_flow'
}
}
// Check if it's a collection/product carousel
if (message.interactiveMessage.carouselMessage?.cards?.length) {
const hasCollectionCards = message.interactiveMessage.carouselMessage.cards.some(
@@ -668,6 +663,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return 'native_flow'
}
}
return 'interactive'
}
@@ -693,6 +689,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (innerMessage.interactiveMessage.nativeFlowMessage) {
return 'native_flow'
}
// Check if it's a carousel with nativeFlowMessage buttons in cards
if (innerMessage.interactiveMessage.carouselMessage?.cards?.length) {
const hasNativeFlowButtons = innerMessage.interactiveMessage.carouselMessage.cards.some(
@@ -702,6 +699,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return 'native_flow'
}
}
// Check if it's a collection/product carousel
if (innerMessage.interactiveMessage.carouselMessage?.cards?.length) {
const hasCollectionCards = innerMessage.interactiveMessage.carouselMessage.cards.some(
@@ -711,6 +709,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return 'native_flow'
}
}
return 'interactive'
}
}
@@ -727,7 +726,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// For native_flow messages, check for special button types that need specific attributes
if (buttonType === 'native_flow') {
const interactiveMsg = message.interactiveMessage ||
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
@@ -758,7 +758,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
* Carousels should NOT have the bot node injected as they are not bot messages
*/
const isCarouselMessage = (message: proto.IMessage): boolean => {
const interactiveMsg = message.interactiveMessage ||
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
@@ -774,7 +775,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
* These messages may need different biz node handling or no biz node at all
*/
const isCatalogMessage = (message: proto.IMessage): boolean => {
const interactiveMsg = message.interactiveMessage ||
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
@@ -782,9 +784,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (nativeFlow?.buttons?.length) {
// Check if any button is a catalog-type button
return nativeFlow.buttons.some(
(btn: any) => btn?.name === 'catalog_message' ||
btn?.name === 'single_product' ||
btn?.name === 'product_list'
(btn: any) => btn?.name === 'catalog_message' || btn?.name === 'single_product' || btn?.name === 'product_list'
)
}
@@ -796,16 +796,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
* Lists need type='list' in the biz node instead of type='native_flow'
*/
const isListNativeFlow = (message: proto.IMessage): boolean => {
const interactiveMsg = message.interactiveMessage ||
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
const nativeFlow = interactiveMsg?.nativeFlowMessage
if (nativeFlow?.buttons?.length) {
// Check if any button is a list-type button (single_select or multi_select)
return nativeFlow.buttons.some(
(btn: any) => btn?.name === 'single_select' || btn?.name === 'multi_select'
)
return nativeFlow.buttons.some((btn: any) => btn?.name === 'single_select' || btn?.name === 'multi_select')
}
return false
@@ -852,9 +851,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const innerMsg = message.viewOnceMessage?.message
const nativeFlow = innerMsg?.interactiveMessage?.nativeFlowMessage
if (nativeFlow?.buttons?.length) {
const singleSelectBtn = nativeFlow.buttons.find(
(btn: any) => btn?.name === 'single_select'
)
const singleSelectBtn = nativeFlow.buttons.find((btn: any) => btn?.name === 'single_select')
if (singleSelectBtn?.buttonParamsJson) {
try {
const params = JSON.parse(singleSelectBtn.buttonParamsJson)
@@ -1156,6 +1153,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.debug({ jid }, '[CAROUSEL] Skipping own device - DSM carousel causes error 479')
continue
}
meRecipients.push(jid)
} else {
otherRecipients.push(jid)
@@ -1212,7 +1210,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
attrs: {
v: '2',
type,
count: participant!.count.toString()
count: participant.count.toString()
},
content: encryptedContent
})
@@ -1257,8 +1255,14 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const startTime = Date.now()
// Debug: Log message structure to diagnose list detection
const interactiveMsg = message.interactiveMessage || message.viewOnceMessage?.message?.interactiveMessage || message.viewOnceMessageV2?.message?.interactiveMessage
const listMsg = message.listMessage || message.viewOnceMessage?.message?.listMessage || message.viewOnceMessageV2?.message?.listMessage
const interactiveMsg =
message.interactiveMessage ||
message.viewOnceMessage?.message?.interactiveMessage ||
message.viewOnceMessageV2?.message?.interactiveMessage
const listMsg =
message.listMessage ||
message.viewOnceMessage?.message?.listMessage ||
message.viewOnceMessageV2?.message?.listMessage
const nativeFlowButtons = interactiveMsg?.nativeFlowMessage?.buttons || []
const isListDetected = isListNativeFlow(message)
@@ -1323,10 +1327,10 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Note: '' (empty) causes error 405 rejection from WhatsApp server
// Special flows (payment, mpm, order) use specific names
const SPECIAL_FLOW_NAMES: Record<string, string> = {
'review_and_pay': 'payment_info',
'payment_info': 'payment_info',
'mpm': 'mpm',
'review_order': 'order_details'
review_and_pay: 'payment_info',
payment_info: 'payment_info',
mpm: 'mpm',
review_order: 'order_details'
}
const firstButtonName = allButtonNames[0] || ''
const nativeFlowName = SPECIAL_FLOW_NAMES[firstButtonName] || 'mixed'
@@ -1370,11 +1374,9 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// - quick_reply buttons with bot node: only smartphone, not Web ❌
const isNativeFlowButtons = buttonType === 'native_flow'
const isPrivateUserChat = (
isPnUser(destinationJid) ||
isLidUser(destinationJid) ||
destinationJid?.endsWith('@c.us')
) && !isJidBot(destinationJid)
const isPrivateUserChat =
(isPnUser(destinationJid) || isLidUser(destinationJid) || destinationJid?.endsWith('@c.us')) &&
!isJidBot(destinationJid)
if (isPrivateUserChat && !isCarousel && !isCatalog && buttonType !== 'list' && !isNativeFlowButtons) {
;(stanza.content as BinaryNode[]).push({
@@ -1406,10 +1408,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
metrics.interactiveMessagesSuccess.inc({ type: buttonType })
metrics.interactiveMessagesLatency.observe({ type: buttonType }, Date.now() - startTime)
} catch (error) {
logger.error(
{ error, msgId, buttonType },
'[EXPERIMENTAL] Failed to inject biz node for interactive message'
)
logger.error({ error, msgId, buttonType }, '[EXPERIMENTAL] Failed to inject biz node for interactive message')
metrics.interactiveMessagesFailures.inc({ type: buttonType, reason: 'injection_failed' })
}
} else if (buttonType && !enableInteractiveMessages) {
@@ -1497,15 +1496,23 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logMessageSent(msgId, destinationJid)
// Record message sent metric
const msgType = message.conversation ? 'text'
: message.imageMessage ? 'image'
: message.videoMessage ? 'video'
: message.audioMessage ? 'audio'
: message.documentMessage ? 'document'
: message.stickerMessage ? 'sticker'
: message.stickerPackMessage ? 'sticker_pack'
: message.reactionMessage ? 'reaction'
: 'other'
const msgType = message.conversation
? 'text'
: message.imageMessage
? 'image'
: message.videoMessage
? 'video'
: message.audioMessage
? 'audio'
: message.documentMessage
? 'document'
: message.stickerMessage
? 'sticker'
: message.stickerPackMessage
? 'sticker_pack'
: message.reactionMessage
? 'reaction'
: 'other'
recordMessageSent(msgType)
// Add message to retry cache if enabled
@@ -1728,17 +1735,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const startTime = Date.now()
const userJid = authState.creds.me!.id
const {
medias,
delay: delayConfig = 'adaptive',
retryCount = 3,
continueOnFailure = true
} = album
const { medias, delay: delayConfig = 'adaptive', retryCount = 3, continueOnFailure = true } = album
// Validation (also done in generateWAMessageContent, but double-check here)
if (!medias || medias.length < 2) {
throw new Boom('Album must have at least 2 media items', { statusCode: 400 })
}
if (medias.length > 10) {
throw new Boom('Album cannot have more than 10 media items (WhatsApp limit)', { statusCode: 400 })
}
@@ -1754,25 +1757,30 @@ export const makeMessagesSocket = (config: SocketConfig) => {
)
// Generate album root message first (with counts of expected media)
const albumRootMsg = await generateWAMessage(jid, {
album: { medias, delay: delayConfig, retryCount, continueOnFailure }
}, {
logger,
userJid,
getUrlInfo: text => getUrlInfo(text, {
thumbnailWidth: linkPreviewImageThumbnailWidth,
fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) },
const albumRootMsg = await generateWAMessage(
jid,
{
album: { medias, delay: delayConfig, retryCount, continueOnFailure }
},
{
logger,
uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined
}),
upload: waUploadToServer,
mediaCache: config.mediaCache,
// Don't spread options here to avoid messageId collision
timestamp: options.timestamp,
quoted: options.quoted,
ephemeralExpiration: options.ephemeralExpiration,
mediaUploadTimeoutMs: options.mediaUploadTimeoutMs
})
userJid,
getUrlInfo: text =>
getUrlInfo(text, {
thumbnailWidth: linkPreviewImageThumbnailWidth,
fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) },
logger,
uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined
}),
upload: waUploadToServer,
mediaCache: config.mediaCache,
// Don't spread options here to avoid messageId collision
timestamp: options.timestamp,
quoted: options.quoted,
ephemeralExpiration: options.ephemeralExpiration,
mediaUploadTimeoutMs: options.mediaUploadTimeoutMs
}
)
const albumKey = albumRootMsg.key
@@ -1788,9 +1796,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
process.nextTick(async () => {
let mutexKey = albumRootMsg.key.remoteJid
if (!mutexKey) {
logger.warn({ msgId: albumRootMsg.key.id }, 'Missing remoteJid in albumRootMsg, using msg.key.id as fallback')
logger.warn(
{ msgId: albumRootMsg.key.id },
'Missing remoteJid in albumRootMsg, using msg.key.id as fallback'
)
mutexKey = albumRootMsg.key.id || 'unknown'
}
await messageMutex.mutex(mutexKey, () => upsertMessage(albumRootMsg, 'append'))
})
}
@@ -1812,7 +1824,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const mediaTypeMultiplier = isVideo ? 2.0 : 1.0
// Later items in album get slightly more delay (cumulative load)
const positionMultiplier = 1 + (index * 0.1)
const positionMultiplier = 1 + index * 0.1
// Add some jitter to prevent predictable patterns
const jitter = Math.random() * 200
@@ -1827,16 +1839,14 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (delayConfig === 'adaptive') {
return calculateAdaptiveDelay(media, index)
}
return delayConfig
}
/**
* Send a single media item with retry logic
*/
const sendMediaWithRetry = async (
media: AlbumMediaItem,
index: number
): Promise<AlbumMediaResult> => {
const sendMediaWithRetry = async (media: AlbumMediaItem, index: number): Promise<AlbumMediaResult> => {
const itemStartTime = Date.now()
let lastError: Error | undefined
let attempts = 0
@@ -1845,16 +1855,17 @@ export const makeMessagesSocket = (config: SocketConfig) => {
attempts = attempt + 1
try {
// Generate message for this media item
// NOTE: Each item needs its own unique messageId, so we don't spread options.messageId
// NOTE: Each item needs its own unique messageId, so we don't spread options.messageId
const mediaMsg = await generateWAMessage(jid, media as AnyMessageContent, {
logger,
userJid,
getUrlInfo: text => getUrlInfo(text, {
thumbnailWidth: linkPreviewImageThumbnailWidth,
fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) },
logger,
uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined
}),
getUrlInfo: text =>
getUrlInfo(text, {
thumbnailWidth: linkPreviewImageThumbnailWidth,
fetchOpts: { timeout: 3_000, ...(httpRequestOptions || {}) },
logger,
uploadImage: generateHighQualityLinkPreview ? waUploadToServer : undefined
}),
upload: waUploadToServer,
mediaCache: config.mediaCache,
// Don't spread ...options to avoid messageId collision
@@ -1874,6 +1885,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (!mediaMsg.message.messageContextInfo) {
mediaMsg.message.messageContextInfo = {}
}
mediaMsg.message.messageContextInfo.messageAssociation = {
associationType: proto.MessageAssociation.AssociationType.MEDIA_ALBUM,
parentMessageKey: albumKey
@@ -1893,14 +1905,12 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.warn({ msgId: mediaMsg.key.id }, 'Missing remoteJid in mediaMsg, using msg.key.id as fallback')
mutexKey = mediaMsg.key.id || 'unknown'
}
await messageMutex.mutex(mutexKey, () => upsertMessage(mediaMsg, 'append'))
})
}
logger.debug(
{ index, msgId: mediaMsg.key.id, attempts },
'Album media item sent successfully'
)
logger.debug({ index, msgId: mediaMsg.key.id, attempts }, 'Album media item sent successfully')
return {
index,
@@ -1925,10 +1935,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
// All retries exhausted
logger.error(
{ index, attempts, error: lastError?.message },
'Album media item failed after all retries'
)
logger.error({ index, attempts, error: lastError?.message }, 'Album media item failed after all retries')
return {
index,
@@ -2005,7 +2012,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (typeof content === 'object' && 'album' in content) {
throw new Boom(
'Cannot send album messages with sendMessage(). Use sendAlbumMessage() instead, ' +
'which properly sends the album root and individual media items.',
'which properly sends the album root and individual media items.',
{ statusCode: 400 }
)
}
@@ -2027,7 +2034,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
upload: waUploadToServer,
mediaCache: config.mediaCache,
options: config.options,
jid,
jid
})
// Pass the plain JS object directly to relayMessage
@@ -2037,7 +2044,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
messageId,
useCachedGroupMetadata: options.useCachedGroupMetadata,
additionalAttributes: {},
statusJidList: options.statusJidList,
statusJidList: options.statusJidList
})
// Build WebMessageInfo only for event emission and return value
@@ -2151,6 +2158,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.warn({ msgId: fullMsg.key.id }, 'Missing remoteJid in fullMsg, using msg.key.id as fallback')
mutexKey = fullMsg.key.id || 'unknown'
}
await messageMutex.mutex(mutexKey, () => upsertMessage(fullMsg, 'append'))
})
}
+6 -4
View File
@@ -28,10 +28,12 @@ const parseNewsletterCreateResponse = (response: NewsletterCreateResponse): News
invite: thread.invite || '',
subscribers: parseInt(thread.subscribers_count, 10) || 0,
verification: thread.verification,
picture: thread.picture ? {
id: thread.picture.id || '',
directPath: thread.picture.direct_path || ''
} : { id: '', directPath: '' },
picture: thread.picture
? {
id: thread.picture.id || '',
directPath: thread.picture.direct_path || ''
}
: { id: '', directPath: '' },
mute_state: viewer?.mute
}
}
+33 -26
View File
@@ -13,8 +13,8 @@ import {
NOISE_WA_HEADER,
UPLOAD_TIMEOUT
} from '../Defaults'
import { makeSessionCleanup } from '../Signal/session-cleanup'
import { makeSessionActivityTracker } from '../Signal/session-activity-tracker'
import { makeSessionCleanup } from '../Signal/session-cleanup'
import type { ConnectionState, LIDMapping, SocketConfig } from '../Types'
import { DisconnectReason } from '../Types'
import {
@@ -44,6 +44,18 @@ import {
createConnectionCircuitBreaker,
createPreKeyCircuitBreaker
} from '../Utils/circuit-breaker'
import {
decrementActiveConnections,
incrementActiveConnections,
recordConnectionAttempt,
recordConnectionError
} from '../Utils/prometheus-metrics'
import {
createUnifiedSessionManager,
extractServerTime,
shouldEnableUnifiedSession,
type UnifiedSessionManager
} from '../Utils/unified-session'
import {
assertNodeErrorFree,
type BinaryNode,
@@ -57,18 +69,6 @@ import {
jidEncode,
S_WHATSAPP_NET
} from '../WABinary'
import {
recordConnectionError,
recordConnectionAttempt,
incrementActiveConnections,
decrementActiveConnections
} from '../Utils/prometheus-metrics'
import {
createUnifiedSessionManager,
extractServerTime,
shouldEnableUnifiedSession,
type UnifiedSessionManager
} from '../Utils/unified-session'
import { BinaryInfo } from '../WAM/BinaryInfo.js'
import { USyncQuery, USyncUser } from '../WAUSync/'
import { WebSocketClient } from './Client'
@@ -103,9 +103,8 @@ export const makeSocket = (config: SocketConfig) => {
} = config
// Resolve enableUnifiedSession: explicit config > env var > default (true)
const enableUnifiedSession = enableUnifiedSessionConfig !== undefined
? enableUnifiedSessionConfig
: shouldEnableUnifiedSession()
const enableUnifiedSession =
enableUnifiedSessionConfig !== undefined ? enableUnifiedSessionConfig : shouldEnableUnifiedSession()
// Initialize circuit breakers if enabled
let queryCircuitBreaker: CircuitBreaker | undefined
@@ -223,6 +222,7 @@ export const makeSocket = (config: SocketConfig) => {
if (error instanceof CircuitOpenError) {
logger.warn({ circuitName: error.circuitName }, 'Send blocked by connection circuit breaker')
}
throw error
}
}
@@ -246,6 +246,7 @@ export const makeSocket = (config: SocketConfig) => {
const sendNodeForSession = async (node: BinaryNode): Promise<void> => {
await sendNode(node)
}
unifiedSessionManager = createUnifiedSessionManager({
enabled: true,
logger,
@@ -342,6 +343,7 @@ export const makeSocket = (config: SocketConfig) => {
if (error instanceof CircuitOpenError) {
logger.warn({ circuitName: error.circuitName, state: error.state }, 'Query blocked by circuit breaker')
}
throw error
}
}
@@ -572,7 +574,7 @@ export const makeSocket = (config: SocketConfig) => {
})
if (sendMsg) {
sendRawMessage(sendMsg).catch(onClose!)
sendRawMessage(sendMsg).catch(onClose!)
}
return result
@@ -857,9 +859,10 @@ export const makeSocket = (config: SocketConfig) => {
// 1. Multiple calls to cleanup (reentrancy guard)
// 2. Timer orphaning: syncLoop creating timer after clearTimeout
if (cleanedUp) {
return // Already cleaned up
return // Already cleaned up
}
cleanedUp = true // ← Set IMMEDIATELY to close race window
cleanedUp = true // ← Set IMMEDIATELY to close race window
ev.off('connection.update', connectionHandler)
if (syncTimer) {
@@ -957,10 +960,12 @@ export const makeSocket = (config: SocketConfig) => {
clearTimeout(ttlTimer)
ttlTimer = undefined
}
if (ttlGraceTimer) {
clearTimeout(ttlGraceTimer)
ttlGraceTimer = undefined
}
sessionStartTime = undefined
logger.info('🕐 Session TTL timers cleared on disconnect')
}
@@ -979,17 +984,20 @@ export const makeSocket = (config: SocketConfig) => {
logger.debug('🕐 Session TTL cleanup already called')
return
}
cleanedUp = true // ← Set IMMEDIATELY to close race window
cleanedUp = true // ← Set IMMEDIATELY to close race window
ev.off('connection.update', connectionHandler)
if (ttlTimer) {
clearTimeout(ttlTimer)
ttlTimer = undefined
}
if (ttlGraceTimer) {
clearTimeout(ttlGraceTimer)
ttlGraceTimer = undefined
}
logger.debug('🕐 Session TTL cleanup function executed')
}
}
@@ -1053,7 +1061,8 @@ export const makeSocket = (config: SocketConfig) => {
logger.trace({ trace: error?.stack }, 'connection already closed')
return
}
closed = true // ← Set IMMEDIATELY to close race window
closed = true // ← Set IMMEDIATELY to close race window
logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed')
@@ -1089,6 +1098,7 @@ export const makeSocket = (config: SocketConfig) => {
default:
errorType = `error_${statusCode}`
}
recordConnectionError(errorType)
recordConnectionAttempt('failure')
}
@@ -1115,7 +1125,7 @@ export const makeSocket = (config: SocketConfig) => {
try {
await Promise.race([
uploadPreKeysPromise,
new Promise<void>((resolve) => setTimeout(resolve, 5000)) // 5s timeout
new Promise<void>(resolve => setTimeout(resolve, 5000)) // 5s timeout
])
logger.debug('Pending pre-key upload completed or timed out')
} catch (error) {
@@ -1138,10 +1148,7 @@ export const makeSocket = (config: SocketConfig) => {
// Detect socket-level session errors that require recreation
const statusCode = (error as Boom)?.output?.statusCode || 0
const isSessionError = (
statusCode === DisconnectReason.badSession ||
statusCode === DisconnectReason.restartRequired
)
const isSessionError = statusCode === DisconnectReason.badSession || statusCode === DisconnectReason.restartRequired
if (isSessionError) {
logger.warn(