Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acfaaff68b | |||
| 4fa7d4ee11 | |||
| cf72068b6c | |||
| 9165a4941e | |||
| a8d9e308bc | |||
| c29fe8e5ac | |||
| d2ceeaadc4 | |||
| 32a2a9b15c | |||
| 31ac5aeb11 | |||
| 45885c7a01 | |||
| f1b43e26a1 | |||
| a3dd21c9d7 | |||
| e1e3d88a1c | |||
| 758ab6d8af | |||
| 569e43f184 | |||
| 79ee40037b | |||
| f032c9b0ea | |||
| 3d79b8fc36 | |||
| f6e976f182 | |||
| 3d9d7bafe4 |
@@ -1,7 +1,7 @@
|
||||
syntax = "proto3";
|
||||
package proto;
|
||||
|
||||
/// WhatsApp Version: 2.3000.1034238531
|
||||
/// WhatsApp Version: 2.3000.1034302344
|
||||
|
||||
message ADVDeviceIdentity {
|
||||
optional uint32 rawId = 1;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":[2,3000,1034258243]}
|
||||
{"version":[2,3000,1034382497]}
|
||||
|
||||
+26
-3
@@ -56,10 +56,33 @@ export const PROCESSABLE_HISTORY_TYPES = [
|
||||
// 6 hours in milliseconds
|
||||
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 = {
|
||||
version: version as WAVersion,
|
||||
versionCheckIntervalMs: SIX_HOURS_MS,
|
||||
browser: Browsers.macOS('Chrome'),
|
||||
browser: resolveDefaultBrowser(),
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||
connectTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 15_000,
|
||||
@@ -67,7 +90,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
emitOwnEvents: true,
|
||||
defaultQueryTimeoutMs: 30_000,
|
||||
customUploadHosts: [],
|
||||
retryRequestDelayMs: 250,
|
||||
retryRequestDelayMs: 150,
|
||||
maxMsgRetryCount: 5,
|
||||
fireInitQueries: true,
|
||||
auth: undefined as unknown as AuthenticationState,
|
||||
@@ -78,7 +101,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
shouldSyncHistoryMessage: () => true,
|
||||
shouldIgnoreJid: () => false,
|
||||
linkPreviewImageThumbnailWidth: 192,
|
||||
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
|
||||
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 1000 },
|
||||
generateHighQualityLinkPreview: false,
|
||||
enableAutoSessionRecreation: true,
|
||||
enableRecentMessageCache: true,
|
||||
|
||||
+46
-19
@@ -50,7 +50,8 @@ import {
|
||||
isMissingKeyError,
|
||||
MAX_SYNC_ATTEMPTS,
|
||||
newLTHashState,
|
||||
processSyncAction
|
||||
processSyncAction,
|
||||
resolveLidToPn
|
||||
} from '../Utils'
|
||||
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
|
||||
import processMessage from '../Utils/process-message'
|
||||
@@ -102,14 +103,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
|
||||
const messageMutex = makeKeyedMutex()
|
||||
|
||||
/** this mutex ensures that receipts are processed in order */
|
||||
const receiptMutex = makeMutex()
|
||||
/** this mutex ensures that receipts from the same chat are processed in order, while allowing parallel processing across chats */
|
||||
const receiptMutex = makeKeyedMutex()
|
||||
|
||||
/** this mutex ensures that app state patches are processed in order */
|
||||
const appStatePatchMutex = makeMutex()
|
||||
|
||||
/** this mutex ensures that notifications are processed in order */
|
||||
const notificationMutex = makeMutex()
|
||||
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
|
||||
const notificationMutex = makeKeyedMutex()
|
||||
|
||||
// Timeout for AwaitingInitialSync state
|
||||
let awaitingSyncTimeout: NodeJS.Timeout | undefined
|
||||
@@ -757,9 +758,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}, authState?.creds?.me?.id || 'resync-app-state')
|
||||
|
||||
const { onMutation } = newAppStateChunkHandler(isInitialSync)
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
for (const key in globalMutationMap) {
|
||||
const mutation = globalMutationMap[key]
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
const jid = attrs.from
|
||||
const participant = attrs.participant || attrs.from
|
||||
if (!jid) {
|
||||
const rawJid = attrs.from
|
||||
const rawParticipant = attrs.participant || attrs.from
|
||||
if (!rawJid) {
|
||||
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) {
|
||||
if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -933,7 +941,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
} else if (Array.isArray(content)) {
|
||||
const [firstChild] = content
|
||||
if (!firstChild) {
|
||||
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
|
||||
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -952,12 +960,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
}
|
||||
|
||||
if (presence) {
|
||||
if (!participant) {
|
||||
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping')
|
||||
if (!rawParticipant) {
|
||||
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
|
||||
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,
|
||||
logger
|
||||
)
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
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:chatstate', handlePresenceUpdate)
|
||||
ws.on('CB:presence', (node: BinaryNode) => {
|
||||
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) => {
|
||||
const { attrs } = getBinaryNodeChild(node, 'dirty')!
|
||||
@@ -1495,7 +1522,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
|
||||
awaitingSyncTimeout = setTimeout(() => {
|
||||
if (syncState === SyncState.AwaitingInitialSync) {
|
||||
logger.warn('Timeout in AwaitingInitialSync (4s), forcing state to Online and flushing buffer')
|
||||
logger.warn('Timeout in AwaitingInitialSync (2s), forcing state to Online and flushing buffer')
|
||||
syncState = SyncState.Online
|
||||
ev.flush()
|
||||
|
||||
@@ -1505,7 +1532,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
|
||||
ev.emit('creds.update', { accountSyncCounter })
|
||||
}
|
||||
}, 4_000)
|
||||
}, 2_000)
|
||||
})
|
||||
|
||||
// When an app state sync key arrives (myAppStateKeyId is set) and there are
|
||||
|
||||
+43
-7
@@ -1,7 +1,7 @@
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
|
||||
import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
|
||||
import { generateMessageIDV2, unixTimestampSeconds } from '../Utils'
|
||||
import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
|
||||
import {
|
||||
type BinaryNode,
|
||||
getBinaryNodeChild,
|
||||
@@ -17,6 +17,43 @@ import { makeChatsSocket } from './chats'
|
||||
export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
const sock = makeChatsSocket(config)
|
||||
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[]) =>
|
||||
query({
|
||||
@@ -31,7 +68,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
|
||||
const groupMetadata = async (jid: string) => {
|
||||
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
|
||||
return extractGroupMetadata(result)
|
||||
return normalizeGroupMetadata(extractGroupMetadata(result))
|
||||
}
|
||||
|
||||
const groupFetchAllParticipating = async () => {
|
||||
@@ -58,16 +95,15 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
if (groupsChild) {
|
||||
const groups = getBinaryNodeChildren(groupsChild, 'group')
|
||||
for (const groupNode of groups) {
|
||||
const meta = extractGroupMetadata({
|
||||
const meta = await normalizeGroupMetadata(extractGroupMetadata({
|
||||
tag: 'result',
|
||||
attrs: {},
|
||||
content: [groupNode]
|
||||
})
|
||||
}))
|
||||
data[meta.id] = meta
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: properly parse LID / PN DATA
|
||||
sock.ev.emit('groups.update', Object.values(data))
|
||||
|
||||
return data
|
||||
@@ -101,7 +137,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
}))
|
||||
}
|
||||
])
|
||||
return extractGroupMetadata(result)
|
||||
return normalizeGroupMetadata(extractGroupMetadata(result))
|
||||
},
|
||||
groupLeave: async (id: string) => {
|
||||
await groupQuery('@g.us', 'set', [
|
||||
@@ -277,7 +313,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
),
|
||||
groupGetInviteInfo: async (code: string) => {
|
||||
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
|
||||
return extractGroupMetadata(results)
|
||||
return normalizeGroupMetadata(extractGroupMetadata(results))
|
||||
},
|
||||
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
|
||||
const content: BinaryNode = ephemeralExpiration
|
||||
|
||||
+1128
-90
File diff suppressed because it is too large
Load Diff
@@ -1611,6 +1611,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
|
||||
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
|
||||
// regardless of whether a token exists. Spread preserves token+timestamp if present.
|
||||
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
|
||||
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
|
||||
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
|
||||
const currentEntry = currentData[tcTokenJid]
|
||||
await authState.keys.set({
|
||||
@@ -1618,7 +1620,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
[tcTokenJid]: {
|
||||
...currentEntry,
|
||||
token: currentEntry?.token ?? Buffer.alloc(0),
|
||||
senderTimestamp: issueTimestamp
|
||||
senderTimestamp: issueTimestamp,
|
||||
realIssueTimestamp: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+27
-8
@@ -38,7 +38,7 @@ import {
|
||||
signedKeyPair,
|
||||
xmppSignedPreKey
|
||||
} from '../Utils'
|
||||
import { getPlatformId } from '../Utils/browser-utils'
|
||||
import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils'
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
@@ -634,8 +634,6 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
}
|
||||
helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
|
||||
|
||||
logger.info({ browser, helloMsg }, 'connected to WA')
|
||||
|
||||
const init = proto.HandshakeMessage.encode(helloMsg).finish()
|
||||
|
||||
const result = await awaitNextMessage<Uint8Array>(init)
|
||||
@@ -1352,6 +1350,25 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
id: jidEncode(phoneNumber, 's.whatsapp.net'),
|
||||
name: '~'
|
||||
}
|
||||
|
||||
// Pair code companion_platform_id must be Chrome (1) when using Android
|
||||
// browser preset. ANDROID_PHONE (16) causes silent timeout (server ignores),
|
||||
// UWP (21) causes "cannot connect device" rejection. Only Chrome (1) works
|
||||
// for pair code via web protocol (WA\x06\x03). The device still appears as
|
||||
// "Android" in linked devices because DeviceProps.platformType=ANDROID_PHONE
|
||||
// is set separately in the registration node.
|
||||
const isAndroid = isAndroidBrowser(browser)
|
||||
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
|
||||
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
|
||||
|
||||
logger.info({
|
||||
pairCode: pairingCode,
|
||||
jid: authState.creds.me.id,
|
||||
companionPlatformId: pairPlatformId,
|
||||
companionPlatformDisplay: pairPlatformDisplay,
|
||||
isAndroid,
|
||||
}, `pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`)
|
||||
|
||||
ev.emit('creds.update', authState.creds)
|
||||
await sendNode({
|
||||
tag: 'iq',
|
||||
@@ -1384,12 +1401,12 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
{
|
||||
tag: 'companion_platform_id',
|
||||
attrs: {},
|
||||
content: getPlatformId(browser[1])
|
||||
content: pairPlatformId
|
||||
},
|
||||
{
|
||||
tag: 'companion_platform_display',
|
||||
attrs: {},
|
||||
content: `${browser[1]} (${browser[0]})`
|
||||
content: pairPlatformDisplay
|
||||
},
|
||||
{
|
||||
tag: 'link_code_pairing_nonce',
|
||||
@@ -1519,7 +1536,9 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
})
|
||||
// login complete
|
||||
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
|
||||
|
||||
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
|
||||
@@ -1667,8 +1686,8 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
// already-authenticated session (no QR re-scan needed). In this case we skip
|
||||
// the offline buffer entirely so live messages are not held hostage waiting for the
|
||||
// server to finish flushing the pending-message backlog (CB:ib,,offline).
|
||||
// For normal restarts (no stale routingInfo) the standard 5 s safety cap applies.
|
||||
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
|
||||
// For normal restarts (no stale routingInfo) the standard 2 s safety cap applies.
|
||||
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
|
||||
let offlineBufferTimeout: NodeJS.Timeout | undefined
|
||||
|
||||
process.nextTick(() => {
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
|
||||
'app-state-sync-version': LTHashState
|
||||
'lid-mapping': string
|
||||
'device-list': string[]
|
||||
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
|
||||
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null }
|
||||
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
|
||||
'identity-key': Uint8Array
|
||||
}
|
||||
|
||||
+43
-1
@@ -1,4 +1,30 @@
|
||||
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
|
||||
export type WACallUpdateType =
|
||||
| 'offer'
|
||||
| 'ringing'
|
||||
| 'timeout'
|
||||
| 'reject'
|
||||
| 'accept'
|
||||
| 'terminate'
|
||||
| 'preaccept'
|
||||
| 'transport'
|
||||
| 'relaylatency'
|
||||
| 'group_update'
|
||||
| 'reminder'
|
||||
| 'heartbeat'
|
||||
| 'mute_v2'
|
||||
| 'enc_rekey'
|
||||
| 'video'
|
||||
| 'relay'
|
||||
|
||||
export type WACallParticipant = {
|
||||
jid?: string
|
||||
/** 'connected' | 'invited' | 'left' etc. */
|
||||
state?: string
|
||||
/** Phone number in s.whatsapp.net format */
|
||||
userPn?: string
|
||||
/** 'admin' for group call link creator */
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type WACallEvent = {
|
||||
chatId: string
|
||||
@@ -13,4 +39,20 @@ export type WACallEvent = {
|
||||
latencyMs?: number
|
||||
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
|
||||
callerPn?: string
|
||||
/** Call link token (forms URL: https://call.whatsapp.com/video/<token>) */
|
||||
linkToken?: string
|
||||
/** JID of who created the call link */
|
||||
linkCreator?: string
|
||||
/** Phone number of link creator */
|
||||
linkCreatorPn?: string
|
||||
/** Media type: 'video' or 'audio' */
|
||||
media?: string
|
||||
/** Max participants for group/link calls */
|
||||
connectedLimit?: number
|
||||
/** Participants in a group/link call */
|
||||
participants?: WACallParticipant[]
|
||||
/** Call duration in ms (from terminate/call_summary) */
|
||||
duration?: number
|
||||
/** Terminate reason (e.g. 'group_call_ended', 'accepted_elsewhere', 'timeout') */
|
||||
terminateReason?: string
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
|
||||
* Individual sticker in a sticker pack
|
||||
*/
|
||||
export type Sticker = {
|
||||
/** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
|
||||
/** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */
|
||||
data: WAMediaUpload
|
||||
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
|
||||
emojis?: string[]
|
||||
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
|
||||
accessibilityLabel?: string
|
||||
/** Force Lottie format detection (auto-detected if omitted) */
|
||||
isLottie?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ export type BrowsersMap = {
|
||||
baileys(browser: string): [string, string, string]
|
||||
windows(browser: string): [string, string, string]
|
||||
appropriate(browser: string): [string, string, string]
|
||||
android(apiLevel: string): [string, string, string]
|
||||
}
|
||||
|
||||
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],
|
||||
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -378,7 +385,7 @@ export const getPlatformId = (browser: unknown): string => {
|
||||
* properties like 'toString' or 'constructor'.
|
||||
*
|
||||
* @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
|
||||
* isValidBrowserPreset('ubuntu') // true
|
||||
@@ -391,6 +398,16 @@ export const getPlatformId = (browser: unknown): string => {
|
||||
* 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 => {
|
||||
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
|
||||
}
|
||||
|
||||
@@ -276,7 +276,6 @@ export const decryptMessageNode = (
|
||||
meLid: string,
|
||||
repository: SignalRepositoryWithLIDStore,
|
||||
logger: ILogger,
|
||||
autoCleanCorrupted = true
|
||||
) => {
|
||||
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
|
||||
return {
|
||||
@@ -422,34 +421,19 @@ export const decryptMessageNode = (
|
||||
if (isRetryExhausted) {
|
||||
logger.error(
|
||||
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 {
|
||||
// First occurrence - log as warning since auto-recovery will attempt
|
||||
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
|
||||
}
|
||||
|
||||
// Automatic cleanup of corrupted session (if enabled)
|
||||
// eslint-disable-next-line max-depth
|
||||
if (autoCleanCorrupted) {
|
||||
// eslint-disable-next-line max-depth
|
||||
try {
|
||||
const deletedCount = await cleanupCorruptedSession(decryptionJid, repository, logger)
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
// Session cleanup is deferred to retry exhaustion (safety net).
|
||||
// The Signal Protocol handles recovery naturally via retry+pkmsg:
|
||||
// Bad MAC -> retry receipt -> sender re-sends as pkmsg -> new session.
|
||||
// Deleting sessions here (hot path) causes cascading failures when
|
||||
// multiple messages from the same contact arrive simultaneously.
|
||||
// See: messages-recv.ts sendRetryRequest() for deferred cleanup.
|
||||
} else if (isSessionRecord) {
|
||||
// Session record errors are transient - retry should handle them
|
||||
// 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
|
||||
* Signal Protocol will automatically recreate the session on next message
|
||||
* Clean up corrupted session by deleting all device sessions for a JID.
|
||||
* 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,
|
||||
repository: SignalRepositoryWithLIDStore,
|
||||
logger: ILogger
|
||||
|
||||
@@ -54,15 +54,15 @@ export interface BufferConfig {
|
||||
*/
|
||||
export function loadBufferConfig(): BufferConfig {
|
||||
return {
|
||||
// perf(inbound-latency): reduced from 15s → 5s so the safety auto-flush fires sooner.
|
||||
// perf(inbound-latency): reduced from 15s → 3s so the safety auto-flush fires sooner.
|
||||
// processNodeWithBuffer always calls ev.flush() explicitly (no-op for this timer),
|
||||
// but socket.ts's offline-phase buffer and any stalled buffer benefit from the lower cap.
|
||||
bufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_TIMEOUT_MS || '5000', 10),
|
||||
bufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_TIMEOUT_MS || '3000', 10),
|
||||
minBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MIN_TIMEOUT_MS || '1000', 10),
|
||||
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '8000', 10),
|
||||
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '3000', 10),
|
||||
maxHistoryCacheSize: parseInt(process.env.BAILEYS_BUFFER_MAX_HISTORY_CACHE || '10000', 10),
|
||||
maxBufferSize: parseInt(process.env.BAILEYS_BUFFER_MAX_SIZE || '5000', 10),
|
||||
flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '100', 10),
|
||||
flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '10', 10),
|
||||
enableAdaptiveTimeout: process.env.BAILEYS_BUFFER_ADAPTIVE_TIMEOUT !== 'false',
|
||||
enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true' || process.env.BAILEYS_PROMETHEUS_ENABLED === 'true',
|
||||
lruCleanupRatio: parseFloat(process.env.BAILEYS_BUFFER_LRU_CLEANUP_RATIO || '0.2'),
|
||||
|
||||
+44
-6
@@ -230,14 +230,22 @@ export const bindWaitForConnectionUpdate = (ev: BaileysEventEmitter) => bindWait
|
||||
* utility that fetches latest baileys version from the master branch.
|
||||
* Use to ensure your WA connection is always on the latest version
|
||||
*/
|
||||
export const fetchLatestBaileysVersion = async (options: RequestInit = {}) => {
|
||||
export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout?: number } = {}) => {
|
||||
const URL = 'https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/index.ts'
|
||||
try {
|
||||
const response = await fetch(URL, {
|
||||
dispatcher: options.dispatcher,
|
||||
method: 'GET',
|
||||
headers: options.headers
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 5000)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(URL, {
|
||||
dispatcher: options.dispatcher,
|
||||
method: 'GET',
|
||||
headers: options.headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status })
|
||||
}
|
||||
@@ -421,6 +429,36 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
|
||||
case 'accept':
|
||||
status = 'accept'
|
||||
break
|
||||
case 'preaccept':
|
||||
status = 'preaccept'
|
||||
break
|
||||
case 'transport':
|
||||
status = 'transport'
|
||||
break
|
||||
case 'relaylatency':
|
||||
status = 'relaylatency'
|
||||
break
|
||||
case 'group_update':
|
||||
status = 'group_update'
|
||||
break
|
||||
case 'reminder':
|
||||
status = 'reminder'
|
||||
break
|
||||
case 'heartbeat':
|
||||
status = 'heartbeat'
|
||||
break
|
||||
case 'mute_v2':
|
||||
status = 'mute_v2'
|
||||
break
|
||||
case 'enc_rekey':
|
||||
status = 'enc_rekey'
|
||||
break
|
||||
case 'video':
|
||||
status = 'video'
|
||||
break
|
||||
case 'relay':
|
||||
status = 'relay'
|
||||
break
|
||||
default:
|
||||
status = 'ringing'
|
||||
break
|
||||
|
||||
@@ -211,15 +211,33 @@ export class MessageRetryManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MAC errors require IMMEDIATE session recreation regardless of history
|
||||
// This handles the case where contact reinstalled WhatsApp (identity key changed)
|
||||
// MAC errors require session recreation, but rate-limited to prevent loops.
|
||||
// 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)) {
|
||||
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++
|
||||
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
|
||||
metrics.signalMacErrors?.inc({ action: 'session_recreation' })
|
||||
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 {
|
||||
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
|
||||
recreate: true
|
||||
|
||||
+101
-28
@@ -19,10 +19,13 @@ import type {
|
||||
WAMessage,
|
||||
WAMessageKey
|
||||
} from '../Types'
|
||||
import type { LIDMappingStore } from '../Signal/lid-mapping'
|
||||
import { WAMessageStubType } from '../Types'
|
||||
import { getContentType, normalizeMessageContent } from '../Utils/messages'
|
||||
import {
|
||||
areJidsSameUser,
|
||||
isAnyLidUser,
|
||||
isAnyPnUser,
|
||||
isHostedLidUser,
|
||||
isHostedPnUser,
|
||||
isJidBroadcast,
|
||||
@@ -113,46 +116,106 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
|
||||
msgKey.remoteJid = message.key.remoteJid
|
||||
// set participant of the message
|
||||
msgKey.participant = msgKey.participant || message.key.participant
|
||||
} else {
|
||||
// fromMe reactions/polls: normalise remoteJid to match the chat JID
|
||||
// ensures DM reaction keys are consistent with group behavior
|
||||
msgKey.remoteJid = message.key.remoteJid
|
||||
// in groups, normalise participant for own messages too
|
||||
if (message.key.participant) {
|
||||
msgKey.participant = msgKey.participant || message.key.participant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a LID JID to its PN equivalent using the LID mapping store.
|
||||
* Returns the original JID if it's not a LID or if no mapping is found.
|
||||
* Safe to call with any JID type (group, newsletter, PN, etc.).
|
||||
*/
|
||||
export const resolveLidToPn = async (
|
||||
jid: string | undefined | null,
|
||||
lidMapping: LIDMappingStore,
|
||||
logger?: ILogger
|
||||
): Promise<string | undefined> => {
|
||||
if (!jid) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isAnyLidUser(jid)) {
|
||||
const pn = await lidMapping.getPNForLID(jid)
|
||||
if (pn) {
|
||||
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
|
||||
}
|
||||
|
||||
return pn || jid
|
||||
}
|
||||
|
||||
return jid
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a WAMessageKey by resolving LID→PN for remoteJid and participant.
|
||||
*/
|
||||
export const normalizeKeyLidToPn = async (
|
||||
key: WAMessageKey,
|
||||
lidMapping: LIDMappingStore,
|
||||
logger?: ILogger
|
||||
): Promise<void> => {
|
||||
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
|
||||
resolveLidToPn(key.remoteJid, lidMapping, logger),
|
||||
resolveLidToPn(key.participant, lidMapping, logger)
|
||||
])
|
||||
if (resolvedRemoteJid) {
|
||||
key.remoteJid = resolvedRemoteJid
|
||||
}
|
||||
|
||||
if (resolvedParticipant) {
|
||||
key.participant = resolvedParticipant
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeMessageJids = async (
|
||||
message: WAMessage,
|
||||
signalRepository: SignalRepositoryWithLIDStore,
|
||||
logger?: ILogger
|
||||
): Promise<void> => {
|
||||
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
|
||||
if (!jid) {
|
||||
return undefined
|
||||
}
|
||||
const lidMapping = signalRepository.lidMapping
|
||||
const key = message.key
|
||||
|
||||
if (isLidUser(jid) || isHostedLidUser(jid)) {
|
||||
const pn = await signalRepository.lidMapping.getPNForLID(jid)
|
||||
if (pn) {
|
||||
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message')
|
||||
} else {
|
||||
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID')
|
||||
}
|
||||
|
||||
return pn || jid
|
||||
}
|
||||
|
||||
return jid
|
||||
// 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
|
||||
}
|
||||
|
||||
// Execute both lookups in parallel instead of sequentially to reduce latency
|
||||
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
|
||||
resolveLidToPn(message.key.remoteJid),
|
||||
resolveLidToPn(message.key.participant)
|
||||
])
|
||||
|
||||
if (resolvedRemoteJid) {
|
||||
message.key.remoteJid = resolvedRemoteJid
|
||||
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
|
||||
}
|
||||
|
||||
if (resolvedParticipant) {
|
||||
message.key.participant = resolvedParticipant
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +589,12 @@ const processMessage = async (
|
||||
'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
|
||||
// TODO: parse through proper message handling utilities (to add relevant key fields)
|
||||
ev.emit('messages.upsert', {
|
||||
@@ -666,9 +735,13 @@ const processMessage = async (
|
||||
response: responseMsg
|
||||
}
|
||||
|
||||
// Normalize creationMsgKey JIDs for the emitted event
|
||||
const normalizedCreationKey = { ...creationMsgKey }
|
||||
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
|
||||
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
key: creationMsgKey,
|
||||
key: normalizedCreationKey,
|
||||
update: {
|
||||
eventResponses: [eventResponse]
|
||||
}
|
||||
@@ -711,7 +784,7 @@ const processMessage = async (
|
||||
})
|
||||
}
|
||||
|
||||
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID
|
||||
const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
|
||||
|
||||
switch (message.messageStubType) {
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER:
|
||||
|
||||
+120
-19
@@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom'
|
||||
import { createHash } from 'crypto'
|
||||
import { zipSync } from 'fflate'
|
||||
import { promises as fs } from 'fs'
|
||||
import { gzipSync, gunzipSync } from 'zlib'
|
||||
import { proto } from '../../WAProto/index.js'
|
||||
import type { MediaType } from '../Defaults/index.js'
|
||||
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
|
||||
@@ -101,6 +102,49 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
|
||||
*
|
||||
* WABA usa mimetype `application/was` para stickers Lottie.
|
||||
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
|
||||
*
|
||||
* Detecção:
|
||||
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
|
||||
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
|
||||
*
|
||||
* @param buffer - Buffer to check
|
||||
* @returns true if buffer is Lottie/WAS format
|
||||
*/
|
||||
export const isLottieBuffer = (buffer: Buffer): boolean => {
|
||||
if (buffer.length < 2) return false
|
||||
|
||||
let jsonBuffer: Buffer
|
||||
|
||||
// Check if gzip-compressed (WAS format)
|
||||
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
||||
try {
|
||||
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
|
||||
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (buffer[0] === 0x7b) {
|
||||
// Starts with '{' - could be raw Lottie JSON
|
||||
jsonBuffer = buffer
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Lottie JSON structure: must have v, ip, op, AND layers
|
||||
try {
|
||||
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
|
||||
// Quick check for ALL required Lottie fields
|
||||
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte uma imagem para WebP usando Sharp
|
||||
* Preserva o buffer original se já for WebP para manter EXIF e animações
|
||||
@@ -114,12 +158,25 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
|
||||
const convertToWebP = async (
|
||||
buffer: Buffer,
|
||||
logger?: ILogger
|
||||
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
|
||||
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
|
||||
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
|
||||
if (isLottieBuffer(buffer)) {
|
||||
let wasBuffer = buffer
|
||||
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
|
||||
if (buffer[0] === 0x7b) {
|
||||
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
|
||||
wasBuffer = gzipSync(buffer) as Buffer
|
||||
}
|
||||
|
||||
logger?.trace('Input is Lottie/WAS format')
|
||||
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
|
||||
}
|
||||
|
||||
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
|
||||
if (isWebPBuffer(buffer)) {
|
||||
const isAnimated = isAnimatedWebP(buffer)
|
||||
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
|
||||
return { webpBuffer: buffer, isAnimated }
|
||||
return { webpBuffer: buffer, isAnimated, isLottie: false }
|
||||
}
|
||||
|
||||
// Tenta usar Sharp para converter
|
||||
@@ -135,7 +192,7 @@ const convertToWebP = async (
|
||||
logger?.trace('Converting image to WebP using Sharp')
|
||||
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
|
||||
|
||||
return { webpBuffer, isAnimated: false }
|
||||
return { webpBuffer, isAnimated: false, isLottie: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,9 +395,11 @@ export type PrepareStickerPackMessageOptions = {
|
||||
*
|
||||
* **Especificações WhatsApp:**
|
||||
* - 3-30 stickers por pack (oficial)
|
||||
* - WebP obrigatório
|
||||
* - WebP ou Lottie/WAS (application/was)
|
||||
* - Stickers: 512x512 pixels (auto-resize)
|
||||
* - Recomendado: 100KB por sticker estático, 500KB animado
|
||||
* - Tray icon: 252x252 pixels
|
||||
* - Tray icon: 96x96 pixels (PNG no ZIP)
|
||||
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
|
||||
*
|
||||
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
|
||||
* @param options - Upload function and optional logger
|
||||
@@ -464,9 +523,36 @@ export const prepareStickerPackMessage = async (
|
||||
// Obtém buffer do sticker
|
||||
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
|
||||
|
||||
// Converte para WebP
|
||||
// Detecta formato e converte se necessário
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
|
||||
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
|
||||
|
||||
// Validate explicit isLottie flag — must match detected format
|
||||
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
|
||||
throw new Boom(
|
||||
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
|
||||
if (!isLottie && isWebPBuffer(webpBuffer)) {
|
||||
const lib = await getImageProcessingLibrary()
|
||||
if (lib?.sharp) {
|
||||
const metadata = await lib.sharp.default(webpBuffer).metadata()
|
||||
if (metadata.width !== 512 || metadata.height !== 512) {
|
||||
logger?.trace(
|
||||
{ index: i, width: metadata.width, height: metadata.height },
|
||||
'Resizing sticker to 512x512 (WABA standard)'
|
||||
)
|
||||
webpBuffer = await lib.sharp
|
||||
.default(webpBuffer)
|
||||
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.webp()
|
||||
.toBuffer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
|
||||
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
|
||||
@@ -483,7 +569,7 @@ export const prepareStickerPackMessage = async (
|
||||
if (lib?.sharp) {
|
||||
// Try quality 70
|
||||
try {
|
||||
const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
|
||||
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer()
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (compressed70.length <= MAX_STICKER_SIZE) {
|
||||
@@ -498,7 +584,7 @@ export const prepareStickerPackMessage = async (
|
||||
)
|
||||
} else {
|
||||
// Try quality 50
|
||||
const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
|
||||
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer()
|
||||
|
||||
// eslint-disable-next-line max-depth
|
||||
if (compressed50.length <= MAX_STICKER_SIZE) {
|
||||
@@ -547,12 +633,13 @@ export const prepareStickerPackMessage = async (
|
||||
)
|
||||
}
|
||||
|
||||
// Gera nome do arquivo: hash.webp (deduplicação automática)
|
||||
// Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext)
|
||||
const sha256Hash = generateSha256Hash(webpBuffer)
|
||||
const fileName = `${sha256Hash}.webp`
|
||||
const extension = isLottie ? 'was' : 'webp'
|
||||
const fileName = `${sha256Hash}.${extension}`
|
||||
|
||||
logger?.trace(
|
||||
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
|
||||
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
|
||||
'Sticker processed successfully'
|
||||
)
|
||||
|
||||
@@ -560,6 +647,7 @@ export const prepareStickerPackMessage = async (
|
||||
fileName,
|
||||
webpBuffer,
|
||||
isAnimated,
|
||||
isLottie,
|
||||
emojis: sticker.emojis || [],
|
||||
accessibilityLabel: sticker.accessibilityLabel
|
||||
}
|
||||
@@ -578,7 +666,7 @@ export const prepareStickerPackMessage = async (
|
||||
for (const result of processedStickers) {
|
||||
if (!result) continue
|
||||
|
||||
const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
|
||||
const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result
|
||||
|
||||
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
|
||||
const existingMetadata = metadataByHash.get(fileName)
|
||||
@@ -605,13 +693,14 @@ export const prepareStickerPackMessage = async (
|
||||
// New sticker - add to ZIP and create metadata
|
||||
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
|
||||
|
||||
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
|
||||
const metadata: proto.Message.StickerPackMessage.ISticker = {
|
||||
fileName,
|
||||
isAnimated,
|
||||
emojis,
|
||||
accessibilityLabel,
|
||||
isLottie: false,
|
||||
mimetype: 'image/webp'
|
||||
isLottie,
|
||||
mimetype: isLottie ? 'application/was' : 'image/webp'
|
||||
}
|
||||
|
||||
metadataByHash.set(fileName, metadata)
|
||||
@@ -636,10 +725,22 @@ export const prepareStickerPackMessage = async (
|
||||
logger?.trace('Processing cover image')
|
||||
coverBuffer = await mediaToBuffer(cover, 'cover image')
|
||||
|
||||
// Converte cover para WebP e adiciona ao ZIP
|
||||
const result = await convertToWebP(coverBuffer, logger)
|
||||
coverWebP = result.webpBuffer
|
||||
coverFileName = `${stickerPackId}.webp`
|
||||
// Tray icon uses PNG format, 96x96 pixels (official client standard)
|
||||
const lib = await getImageProcessingLibrary()
|
||||
if (!lib?.sharp) {
|
||||
throw new Boom(
|
||||
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
|
||||
{ statusCode: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
coverWebP = await lib.sharp
|
||||
.default(coverBuffer)
|
||||
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
coverFileName = `${stickerPackId}.png`
|
||||
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
|
||||
} catch (error) {
|
||||
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
|
||||
|
||||
@@ -159,7 +159,10 @@ export async function storeTcTokensFromIqResult({
|
||||
[storageJid]: {
|
||||
...existingEntry,
|
||||
token: Buffer.from(tokenNode.content),
|
||||
timestamp: tokenNode.attrs.t
|
||||
timestamp: tokenNode.attrs.t,
|
||||
// WABA Android: resets real_issue_timestamp to null when storing a new token
|
||||
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
|
||||
realIssueTimestamp: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,19 +14,24 @@ import { encodeBigEndian } from './generics'
|
||||
import { createSignalIdentity } from './signal'
|
||||
|
||||
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
|
||||
// Always use MACOS/Desktop identity for UserAgent — we connect via web
|
||||
// protocol (WA\x06\x03) so the server expects a web-like UserAgent.
|
||||
// Using SMB_ANDROID here causes pair code registration to fail with
|
||||
// "não é possível conectar" even though the phone shows the confirmation.
|
||||
// Android identity is only set in DeviceProps (registration node) which
|
||||
// determines the display name in "Linked Devices".
|
||||
return {
|
||||
appVersion: {
|
||||
primary: config.version[0],
|
||||
secondary: config.version[1],
|
||||
tertiary: config.version[2]
|
||||
},
|
||||
platform: proto.ClientPayload.UserAgent.Platform.WEB,
|
||||
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
|
||||
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
|
||||
osVersion: '0.1',
|
||||
device: 'Desktop',
|
||||
osBuildNumber: '0.1',
|
||||
localeLanguageIso6391: 'en',
|
||||
|
||||
mnc: '000',
|
||||
mcc: '000',
|
||||
localeCountryIso31661Alpha2: config.countryCode
|
||||
@@ -55,11 +60,10 @@ const getClientPayload = (config: SocketConfig) => {
|
||||
const payload: proto.IClientPayload = {
|
||||
connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN,
|
||||
connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED,
|
||||
userAgent: getUserAgent(config)
|
||||
userAgent: getUserAgent(config),
|
||||
webInfo: getWebInfo(config)
|
||||
}
|
||||
|
||||
payload.webInfo = getWebInfo(config)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -84,6 +88,10 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
|
||||
|
||||
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
|
||||
const platformType = platform.toUpperCase()
|
||||
if (platformType === 'ANDROID') {
|
||||
return proto.DeviceProps.PlatformType.ANDROID_PHONE
|
||||
}
|
||||
|
||||
return (
|
||||
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
|
||||
proto.DeviceProps.PlatformType.CHROME
|
||||
|
||||
@@ -20,7 +20,7 @@ import { jest } from '@jest/globals'
|
||||
* bad-ack-handling.test.ts.
|
||||
*/
|
||||
|
||||
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
|
||||
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
|
||||
|
||||
/** Mirrors the state variables declared at the top of makeSocket */
|
||||
interface OfflineBufferState {
|
||||
@@ -101,10 +101,10 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Timeout path — CB:ib,,offline never arrives within 5 s
|
||||
// 1. Timeout path — CB:ib,,offline never arrives within 2 s
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('fires after 5 s and flushes when CB:ib,,offline is delayed', () => {
|
||||
it('fires after 2 s and flushes when CB:ib,,offline is delayed', () => {
|
||||
startBuffer(state, mockFlush, mockWarn)
|
||||
|
||||
expect(mockFlush).not.toHaveBeenCalled()
|
||||
@@ -144,17 +144,17 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Happy path — CB:ib,,offline arrives before the 5 s timer fires
|
||||
// 2. Happy path — CB:ib,,offline arrives before the 2 s timer fires
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('CB:ib,,offline cancels the timer and flushes exactly once', () => {
|
||||
startBuffer(state, mockFlush, mockWarn)
|
||||
|
||||
// Server responds before the 5 s timeout
|
||||
// Server responds before the 2 s timeout
|
||||
jest.advanceTimersByTime(1_000)
|
||||
onOffline(state, mockFlush)
|
||||
|
||||
// Timer should be cancelled — advancing past 5 s must not cause a second flush
|
||||
// Timer should be cancelled — advancing past 2 s must not cause a second flush
|
||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||
|
||||
expect(mockFlush).toHaveBeenCalledTimes(1)
|
||||
@@ -192,7 +192,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
||||
|
||||
onClose(state)
|
||||
|
||||
// Timer must be gone — advancing past 5 s must not trigger any flush
|
||||
// Timer must be gone — advancing past 2 s must not trigger any flush
|
||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||
|
||||
expect(mockFlush).not.toHaveBeenCalled()
|
||||
@@ -236,7 +236,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
||||
// 4. Boundary / timing edge cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('does not flush before exactly 5 s have elapsed', () => {
|
||||
it('does not flush before exactly 2 s have elapsed', () => {
|
||||
startBuffer(state, mockFlush, mockWarn)
|
||||
|
||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS - 1)
|
||||
@@ -244,7 +244,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
||||
expect(mockFlush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes at exactly the 5 s boundary', () => {
|
||||
it('flushes at exactly the 2 s boundary', () => {
|
||||
startBuffer(state, mockFlush, mockWarn)
|
||||
|
||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||
|
||||
Reference in New Issue
Block a user