UNPKG

@whiskeysockets/baileys

Version:

A WebSockets library for interacting with WhatsApp Web

1,032 lines 87.4 kB
import NodeCache from '@cacheable/node-cache'; import { Boom } from '@hapi/boom'; import { randomBytes } from 'crypto'; import Long from 'long'; import { proto } from '../../WAProto/index.js'; import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js'; import { ReachoutTimelockEnforcementType, WAMessageStatus, WAMessageStubType } from '../Types/index.js'; import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js'; import { makeMutex } from '../Utils/make-mutex.js'; import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js'; import { buildAckStanza } from '../Utils/stanza-ack.js'; import { buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resolveIssuanceJid, resolveTcTokenJid, storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY } from '../Utils/tc-token-utils.js'; import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js'; import { extractGroupMetadata } from './groups.js'; import { makeMessagesSocket } from './messages-send.js'; const ENFORCEMENT_TYPE_VALUES = new Set(Object.values(ReachoutTimelockEnforcementType)); function isValidEnforcementType(value) { return typeof value === 'string' && ENFORCEMENT_TYPE_VALUES.has(value); } export const makeMessagesRecvSocket = (config) => { const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config; const sock = makeMessagesSocket(config); const { userDevicesCache, devicesMutex, ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, registerSocketEndHandler, issuePrivacyTokens, fetchAccountReachoutTimelock, placeholderResendCache } = sock; const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping); /** this mutex ensures that each retryRequest will wait for the previous one to finish */ const retryMutex = makeMutex(); const msgRetryCache = config.msgRetryCounterCache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour useClones: false }); const callOfferCache = config.callOfferCache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins useClones: false }); // Debounce identity-change session refreshes per JID to avoid bursts const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false }); let sendActiveReceipts = false; const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => { if (!authState.creds.me?.id) { throw new Boom('Not authenticated'); } const pdoMessage = { historySyncOnDemandRequest: { chatJid: oldestMsgKey.remoteJid, oldestMsgFromMe: oldestMsgKey.fromMe, oldestMsgId: oldestMsgKey.id, oldestMsgTimestampMs: oldestMsgTimestamp, onDemandMsgCount: count }, peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND }; return sendPeerDataOperationMessage(pdoMessage); }; const requestPlaceholderResend = async (messageKey, msgData) => { if (!authState.creds.me?.id) { throw new Boom('Not authenticated'); } if (await placeholderResendCache.get(messageKey?.id)) { logger.debug({ messageKey }, 'already requested resend'); return; } else { // Store original message data so PDO response handler can preserve // metadata (LID details, timestamps, etc.) that the phone may omit await placeholderResendCache.set(messageKey?.id, msgData || true); } await delay(2000); if (!(await placeholderResendCache.get(messageKey?.id))) { logger.debug({ messageKey }, 'message received while resend requested'); return 'RESOLVED'; } const pdoMessage = { placeholderMessageResendRequest: [ { messageKey } ], peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND }; setTimeout(async () => { if (await placeholderResendCache.get(messageKey?.id)) { logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline'); await placeholderResendCache.del(messageKey?.id); } }, 8000); return sendPeerDataOperationMessage(pdoMessage); }; const handleMexNotification = async (node) => { const updateNode = getBinaryNodeChild(node, 'update'); if (updateNode) { const opName = updateNode.attrs?.op_name; if (!opName) { logger.warn({ node: binaryNodeToString(node) }, 'mex notification missing op_name, fallback to legacy'); await handleLegacyMexNewsletterNotification(node); return; } let mexResponse; try { mexResponse = JSON.parse(updateNode.content.toString()); } catch (error) { logger.error({ err: error, opName }, 'failed to parse mex notification JSON'); return; } if (mexResponse.errors?.length) { logger.warn({ errors: mexResponse.errors, opName }, 'mex notification has GQL errors'); return; } const data = mexResponse.data; if (!data) { logger.warn({ opName }, 'mex notification has null data'); return; } logger.debug({ opName }, 'processing mex notification'); switch (opName) { case 'NotificationUserReachoutTimelockUpdate': handleReachoutTimelockNotification(data); break; case 'MessageCappingInfoNotification': handleMessageCappingNotification(data); break; // newsletter ops still use the legacy <mex> child structure case 'NotificationNewsletterUpdate': case 'NotificationLinkedProfilesUpdates': case 'NotificationNewsletterAdminPromote': case 'NotificationNewsletterAdminDemote': case 'NotificationNewsletterUserSettingChange': case 'NotificationNewsletterJoin': case 'NotificationNewsletterLeave': case 'NotificationNewsletterStateChange': case 'NotificationNewsletterAdminMetadataUpdate': case 'NotificationNewsletterOwnerUpdate': case 'NotificationNewsletterAdminInviteRevoke': case 'NotificationNewsletterWamoSubStatusChange': case 'NotificationNewsletterBlockUser': case 'NotificationNewsletterPaidPartnership': case 'NotificationNewsletterMilestone': case 'NewsletterResponseStateUpdate': await handleLegacyMexNewsletterNotification(node); break; default: logger.debug({ opName }, 'unhandled mex notification'); break; } return; } await handleLegacyMexNewsletterNotification(node); }; const handleReachoutTimelockNotification = (data) => { const payload = data.xwa2_notify_account_reachout_timelock; if (!payload) { logger.warn('reachout timelock notification missing payload'); return; } if (!payload.is_active) { logger.info('reachout timelock restriction lifted'); ev.emit('connection.update', { reachoutTimeLock: { isActive: false, enforcementType: ReachoutTimelockEnforcementType.DEFAULT } }); return; } // WA Web defaults to now+60s when the server omits the expiry const timeEnforcementEnds = payload.time_enforcement_ends ? new Date(parseInt(payload.time_enforcement_ends, 10) * 1000) : new Date(Date.now() + 60000); const enforcementType = isValidEnforcementType(payload.enforcement_type) ? payload.enforcement_type : ReachoutTimelockEnforcementType.DEFAULT; logger.info({ enforcementType, timeEnforcementEnds }, 'reachout timelock restriction set'); ev.emit('connection.update', { reachoutTimeLock: { isActive: true, timeEnforcementEnds, enforcementType } }); }; const handleMessageCappingNotification = (data) => { const payload = data.xwa2_notify_new_chat_messages_capping_info_update; if (!payload) { logger.warn('message capping notification missing payload'); return; } logger.info({ payload }, 'received message capping update'); ev.emit('message-capping.update', payload); }; const handleLegacyMexNewsletterNotification = async (node) => { const mexNode = getBinaryNodeChild(node, 'mex'); const updateNode = mexNode?.content ? null : getBinaryNodeChild(node, 'update') || getAllBinaryNodeChildren(node)[0]; const payloadNode = mexNode?.content ? mexNode : updateNode; if (!payloadNode?.content) { logger.warn({ node: binaryNodeToString(node) }, 'invalid mex newsletter notification'); return; } let data; try { const payloadContent = payloadNode.content; if (Array.isArray(payloadContent)) { logger.warn({ payloadNode }, 'invalid mex newsletter notification payload format'); return; } const contentBuf = typeof payloadContent === 'string' ? Buffer.from(payloadContent, 'binary') : Buffer.from(payloadContent); data = JSON.parse(contentBuf.toString()); } catch (error) { logger.error({ err: error, node: binaryNodeToString(node) }, 'failed to parse mex newsletter notification'); return; } const operation = data?.operation ?? payloadNode?.attrs?.op_name; let updates = data?.updates; if (!updates) { const linkedProfiles = data?.data?.xwa2_notify_linked_profiles; if (linkedProfiles) { updates = [linkedProfiles]; } } if (!updates || !operation) { logger.warn({ data }, 'invalid mex newsletter notification content'); return; } logger.info({ operation, updates }, 'got mex newsletter notification'); switch (operation) { case 'NotificationNewsletterUpdate': for (const update of updates) { if (update.jid && update.settings && Object.keys(update.settings).length > 0) { ev.emit('newsletter-settings.update', { id: update.jid, update: update.settings }); } } break; case 'NotificationNewsletterAdminPromote': for (const update of updates) { if (update.jid && update.user) { ev.emit('newsletter-participants.update', { id: update.jid, author: node.attrs.from, user: update.user, new_role: 'ADMIN', action: 'promote' }); } } break; case 'NotificationLinkedProfilesUpdates': for (const update of updates) { const lid = update?.jid; const addedProfiles = Array.isArray(update?.added_profiles) ? update.added_profiles : []; const mappings = []; for (const profile of addedProfiles) { const pn = typeof profile === 'string' ? profile : (profile?.pn ?? profile?.jid ?? null); if (lid && pn) { const mapping = { lid, pn }; ev.emit('lid-mapping.update', mapping); mappings.push(mapping); } } await signalRepository.lidMapping.storeLIDPNMappings(mappings); } break; default: logger.info({ operation, data }, 'unhandled mex newsletter notification'); break; } }; // Handles newsletter notifications const handleNewsletterNotification = async (node) => { const from = node.attrs.from; const children = getAllBinaryNodeChildren(node); const author = node.attrs.participant; for (const child of children) { logger.debug({ from, child }, 'got newsletter notification'); switch (child.tag) { case 'reaction': { const reactionUpdate = { id: from, server_id: child.attrs.message_id, reaction: { code: getBinaryNodeChildString(child, 'reaction'), count: 1 } }; ev.emit('newsletter.reaction', reactionUpdate); break; } case 'view': { const viewUpdate = { id: from, server_id: child.attrs.message_id, count: parseInt(child.content?.toString() || '0', 10) }; ev.emit('newsletter.view', viewUpdate); break; } case 'participant': { const participantUpdate = { id: from, author, user: child.attrs.jid, action: child.attrs.action, new_role: child.attrs.role }; ev.emit('newsletter-participants.update', participantUpdate); break; } case 'update': { const settingsNode = getBinaryNodeChild(child, 'settings'); if (settingsNode) { const update = {}; const nameNode = getBinaryNodeChild(settingsNode, 'name'); if (nameNode?.content) update.name = nameNode.content.toString(); const descriptionNode = getBinaryNodeChild(settingsNode, 'description'); if (descriptionNode?.content) update.description = descriptionNode.content.toString(); ev.emit('newsletter-settings.update', { id: from, update }); } break; } case 'message': { const plaintextNode = getBinaryNodeChild(child, 'plaintext'); if (plaintextNode?.content) { try { const contentBuf = typeof plaintextNode.content === 'string' ? Buffer.from(plaintextNode.content, 'binary') : Buffer.from(plaintextNode.content); const messageProto = proto.Message.decode(contentBuf).toJSON(); const fullMessage = proto.WebMessageInfo.fromObject({ key: { remoteJid: from, id: child.attrs.message_id || child.attrs.server_id, fromMe: false // TODO: is this really true though }, message: messageProto, messageTimestamp: +child.attrs.t }).toJSON(); await upsertMessage(fullMessage, 'append'); logger.debug('Processed plaintext newsletter message'); } catch (error) { logger.error({ error }, 'Failed to decode plaintext newsletter message'); } } break; } default: logger.warn({ node, child }, 'Unknown newsletter notification child'); break; } } }; const sendMessageAck = async (node, errorCode) => { const stanza = buildAckStanza(node, errorCode, authState.creds.me.id); logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack'); await sendNode(stanza); }; const rejectCall = async (callId, callFrom) => { const stanza = { tag: 'call', attrs: { from: authState.creds.me.id, to: callFrom }, content: [ { tag: 'reject', attrs: { 'call-id': callId, 'call-creator': callFrom, count: '0' }, content: undefined } ] }; await query(stanza); }; const sendRetryRequest = async (node, forceIncludeKeys = false) => { const { fullMessage } = decodeMessageNode(node, authState.creds.me.id, authState.creds.me.lid || ''); const { key: msgKey } = fullMessage; const msgId = msgKey.id; if (messageRetryManager) { // Check if we've exceeded max retries using the new system if (messageRetryManager.hasExceededMaxRetries(msgId)) { logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing'); messageRetryManager.markRetryFailed(msgId); return; } // Increment retry count using new system const retryCount = messageRetryManager.incrementRetryCount(msgId); // Use the new retry count for the rest of the logic const key = `${msgId}:${msgKey?.participant}`; await msgRetryCache.set(key, retryCount); } else { // Fallback to old system const key = `${msgId}:${msgKey?.participant}`; let retryCount = (await msgRetryCache.get(key)) || 0; if (retryCount >= maxMsgRetryCount) { logger.debug({ retryCount, msgId }, 'reached retry limit, clearing'); await msgRetryCache.del(key); return; } retryCount += 1; await msgRetryCache.set(key, retryCount); } const key = `${msgId}:${msgKey?.participant}`; const retryCount = (await msgRetryCache.get(key)) || 1; const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds; const fromJid = node.attrs.from; // Check if we should recreate the session let shouldRecreateSession = false; let recreateReason = ''; if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) { try { // Check if we have a session with this JID const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid); const hasSession = await signalRepository.validateSession(fromJid); const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists); shouldRecreateSession = result.recreate; recreateReason = result.reason; if (shouldRecreateSession) { logger.debug({ fromJid, retryCount, reason: recreateReason }, 'recreating session for retry'); // Delete existing session to force recreation await authState.keys.set({ session: { [sessionId]: null } }); forceIncludeKeys = true; } } catch (error) { logger.warn({ error, fromJid }, 'failed to check session recreation'); } } if (retryCount <= 2) { // Use new retry manager for phone requests if available if (messageRetryManager) { // Schedule phone request with delay (like whatsmeow) messageRetryManager.schedulePhoneRequest(msgId, async () => { try { const requestId = await requestPlaceholderResend(msgKey); logger.debug(`sendRetryRequest: requested placeholder resend (${requestId}) for message ${msgId} (scheduled)`); } catch (error) { logger.warn({ error, msgId }, 'failed to send scheduled phone request'); } }); } else { // Fallback to immediate request const msgId = await requestPlaceholderResend(msgKey); logger.debug(`sendRetryRequest: requested placeholder resend for message ${msgId}`); } } const deviceIdentity = encodeSignedDeviceIdentity(account, true); await authState.keys.transaction(async () => { const receipt = { tag: 'receipt', attrs: { id: msgId, type: 'retry', to: node.attrs.from }, content: [ { tag: 'retry', attrs: { count: retryCount.toString(), id: node.attrs.id, t: node.attrs.t, v: '1', // ADD ERROR FIELD error: '0' } }, { tag: 'registration', attrs: {}, content: encodeBigEndian(authState.creds.registrationId) } ] }; if (node.attrs.recipient) { receipt.attrs.recipient = node.attrs.recipient; } if (node.attrs.participant) { receipt.attrs.participant = node.attrs.participant; } if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) { const { update, preKeys } = await getNextPreKeys(authState, 1); const [keyId] = Object.keys(preKeys); const key = preKeys[+keyId]; const content = receipt.content; content.push({ tag: 'keys', attrs: {}, content: [ { tag: 'type', attrs: {}, content: Buffer.from(KEY_BUNDLE_TYPE) }, { tag: 'identity', attrs: {}, content: identityKey.public }, xmppPreKey(key, +keyId), xmppSignedPreKey(signedPreKey), { tag: 'device-identity', attrs: {}, content: deviceIdentity } ] }); ev.emit('creds.update', update); } await sendNode(receipt); logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt'); }, authState?.creds?.me?.id || 'sendRetryRequest'); }; // Mirrors WAWeb/Handle/PreKeyLow.js: skip a re-issued notification with the same stanza id. const inFlightPreKeyLow = new Set(); /** * Fire-and-forget tctoken re-issuance after a peer's device identity changed. * Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in parallel with * the session refresh (not after it). */ const reissueTcTokenAfterIdentityChange = (from) => { void (async () => { const normalizedJid = jidNormalizedUser(from); const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN); const tcTokenData = await authState.keys.get('tctoken', [tcJid]); const senderTs = tcTokenData?.[tcJid]?.senderTimestamp; if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) { return; } logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken'); const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping); const issueJid = await resolveIssuanceJid(normalizedJid, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID); const result = await issuePrivacyTokens([issueJid], senderTs); await storeTcTokensFromIqResult({ result, fallbackJid: tcJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid }); })().catch(err => { logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change'); }); }; const handleEncryptNotification = async (node) => { const from = node.attrs.from; if (from === S_WHATSAPP_NET) { const stanzaId = node.attrs.id; if (stanzaId && inFlightPreKeyLow.has(stanzaId)) { return; } const countChild = getBinaryNodeChild(node, 'count'); const count = +countChild.attrs.value; const shouldUploadMorePreKeys = count < MIN_PREKEY_COUNT; logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count'); if (shouldUploadMorePreKeys) { if (stanzaId) inFlightPreKeyLow.add(stanzaId); try { await uploadPreKeys(); } finally { if (stanzaId) inFlightPreKeyLow.delete(stanzaId); } } } else { const result = await handleIdentityChange(node, { meId: authState.creds.me?.id, meLid: authState.creds.me?.lid, validateSession: signalRepository.validateSession, assertSessions, debounceCache: identityAssertDebounce, logger, onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange }); if (result.action === 'no_identity_node') { logger.info({ node }, 'unknown encrypt notification'); } } }; const handleGroupNotification = (fullNode, child, msg) => { // TODO: Support PN/LID (Here is only LID now) const actingParticipantLid = fullNode.attrs.participant; const actingParticipantPn = fullNode.attrs.participant_pn; const actingParticipantUsername = fullNode.attrs.participant_username; const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid; const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn; switch (child?.tag) { case 'create': const metadata = extractGroupMetadata(child); msg.messageStubType = WAMessageStubType.GROUP_CREATE; msg.messageStubParameters = [metadata.subject]; msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn }; ev.emit('chats.upsert', [ { id: metadata.id, name: metadata.subject, conversationTimestamp: metadata.creation } ]); ev.emit('groups.upsert', [ { ...metadata, author: actingParticipantLid, authorPn: actingParticipantPn, authorUsername: actingParticipantUsername } ]); break; case 'ephemeral': case 'not_ephemeral': msg.message = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: +(child.attrs.expiration || 0) } }; break; case 'modify': const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid); msg.messageStubParameters = oldNumber || []; msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER; break; case 'promote': case 'demote': case 'remove': case 'add': case 'leave': const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`; msg.messageStubType = WAMessageStubType[stubType]; const participants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => { // TODO: Store LID MAPPINGS return { id: attrs.jid, phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined, username: attrs.participant_username || attrs.username || undefined, admin: (attrs.type || null) }; }); if (participants.length === 1 && // if recv. "remove" message and sender removed themselves // mark as left (areJidsSameUser(participants[0].id, actingParticipantLid) || areJidsSameUser(participants[0].id, actingParticipantPn)) && child.tag === 'remove') { msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE; } msg.messageStubParameters = participants.map(a => JSON.stringify(a)); break; case 'subject': msg.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT; msg.messageStubParameters = [child.attrs.subject]; break; case 'description': const description = getBinaryNodeChild(child, 'body')?.content?.toString(); msg.messageStubType = WAMessageStubType.GROUP_CHANGE_DESCRIPTION; msg.messageStubParameters = description ? [description] : undefined; break; case 'announcement': case 'not_announcement': msg.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE; msg.messageStubParameters = [child.tag === 'announcement' ? 'on' : 'off']; break; case 'locked': case 'unlocked': msg.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT; msg.messageStubParameters = [child.tag === 'locked' ? 'on' : 'off']; break; case 'invite': msg.messageStubType = WAMessageStubType.GROUP_CHANGE_INVITE_LINK; msg.messageStubParameters = [child.attrs.code]; break; case 'member_add_mode': const addMode = child.content; if (addMode) { msg.messageStubType = WAMessageStubType.GROUP_MEMBER_ADD_MODE; msg.messageStubParameters = [addMode.toString()]; } break; case 'membership_approval_mode': const approvalMode = getBinaryNodeChild(child, 'group_join'); if (approvalMode) { msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE; msg.messageStubParameters = [approvalMode.attrs.state]; } break; case 'created_membership_requests': msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD; msg.messageStubParameters = [ JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), 'created', child.attrs.request_method ]; break; case 'revoked_membership_requests': const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid); // TODO: LIDMAPPING SUPPORT msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD; msg.messageStubParameters = [ JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), isDenied ? 'revoked' : 'rejected' ]; break; } }; const handleDevicesNotification = async (node) => { const [child] = getAllBinaryNodeChildren(node); const from = jidNormalizedUser(node.attrs.from); if (!child) { logger.debug({ from }, 'devices notification missing child, skipping'); return; } const tag = child.tag; const deviceHash = child.attrs.device_hash; const devices = getBinaryNodeChildren(child, 'device'); if (areJidsSameUser(from, authState.creds.me.id) || areJidsSameUser(from, authState.creds.me.lid)) { const deviceJids = devices.map(d => d.attrs.jid); logger.info({ deviceJids }, 'got my own devices'); } if (!devices.length) { logger.debug({ from, tag }, 'no devices in notification, skipping'); return; } const decoded = []; for (const d of devices) { const jid = d.attrs.jid; if (!jid) continue; const parts = jidDecode(jid); if (!parts) { logger.debug({ jid }, 'failed to decode device jid, skipping'); continue; } decoded.push({ jid, user: parts.user, server: parts.server, device: parts.device }); } if (!decoded.length) return; await devicesMutex.mutex(async () => { const byUser = new Map(); for (const d of decoded) { const list = byUser.get(d.user) || []; list.push(d); byUser.set(d.user, list); } for (const [user, entries] of byUser) { if (tag === 'update') { logger.debug({ user }, `${user}'s device list updated, dropping cached devices`); await userDevicesCache?.del(user); continue; } if (tag === 'remove') { await signalRepository.deleteSession(entries.map(e => e.jid)); } const existingCache = (await userDevicesCache?.get(user)) || []; if (!existingCache.length) { // No baseline yet; skip applying the delta so getUSyncDevices can // later fetch the full device list. Caching just the notification // entries would make a partial list look authoritative. logger.debug({ user, tag }, 'device list not cached, deferring to USync refresh'); continue; } const affected = new Set(entries.map(e => e.device)); let updatedDevices; switch (tag) { case 'add': logger.info({ deviceHash, count: entries.length }, 'devices added'); updatedDevices = [ ...existingCache.filter(d => !affected.has(d.device)), ...entries.map(e => ({ user: e.user, server: e.server, device: e.device })) ]; break; case 'remove': logger.info({ deviceHash, count: entries.length }, 'devices removed'); updatedDevices = existingCache.filter(d => !affected.has(d.device)); break; default: logger.debug({ tag }, 'Unknown device list change tag'); continue; } if (updatedDevices.length === 0) { await userDevicesCache?.del(user); } else { await userDevicesCache?.set(user, updatedDevices); } } }); }; const processNotification = async (node) => { const result = {}; const [child] = getAllBinaryNodeChildren(node); const nodeType = node.attrs.type; const from = jidNormalizedUser(node.attrs.from); switch (nodeType) { case 'newsletter': await handleNewsletterNotification(node); break; case 'mex': await handleMexNotification(node); break; case 'w:gp2': // TODO: HANDLE PARTICIPANT_PN handleGroupNotification(node, child, result); break; case 'mediaretry': const event = decodeMediaRetryNode(node); ev.emit('messages.media-update', [event]); break; case 'encrypt': await handleEncryptNotification(node); break; case 'devices': try { await handleDevicesNotification(node); } catch (error) { logger.error({ error, node }, 'failed to handle devices notification'); } break; case 'server_sync': const update = getBinaryNodeChild(node, 'collection'); if (update) { const name = update.attrs.name; await resyncAppState([name], false); } break; case 'picture': const setPicture = getBinaryNodeChild(node, 'set'); const delPicture = getBinaryNodeChild(node, 'delete'); // TODO: WAJIDHASH stuff proper support inhouse ev.emit('contacts.update', [ { id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', imgUrl: setPicture ? 'changed' : 'removed' } ]); if (isJidGroup(from)) { const node = setPicture || delPicture; result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON; if (setPicture) { result.messageStubParameters = [setPicture.attrs.id]; } result.participant = node?.attrs.author; result.key = { ...(result.key || {}), participant: setPicture?.attrs.author }; } break; case 'account_sync': if (child.tag === 'disappearing_mode') { const newDuration = +child.attrs.duration; const timestamp = +child.attrs.t; logger.info({ newDuration }, 'updated account disappearing mode'); ev.emit('creds.update', { accountSettings: { ...authState.creds.accountSettings, defaultDisappearingMode: { ephemeralExpiration: newDuration, ephemeralSettingTimestamp: timestamp } } }); } else if (child.tag === 'blocklist') { const blocklists = getBinaryNodeChildren(child, 'item'); for (const { attrs } of blocklists) { const blocklist = [attrs.jid]; const type = attrs.action === 'block' ? 'add' : 'remove'; ev.emit('blocklist.update', { blocklist, type }); } } break; case 'link_code_companion_reg': const linkCodeCompanionReg = getBinaryNodeChild(node, 'link_code_companion_reg'); const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_ref')); const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub')); const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_wrapped_primary_ephemeral_pub')); const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped); const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey); const random = randomBytes(32); const linkCodeSalt = randomBytes(32); const linkCodePairingExpanded = hkdf(companionSharedKey, 32, { salt: linkCodeSalt, info: 'link_code_pairing_key_bundle_encryption_key' }); const encryptPayload = Buffer.concat([ Buffer.from(authState.creds.signedIdentityKey.public), primaryIdentityPublicKey, random ]); const encryptIv = randomBytes(12); const encrypted = aesEncryptGCM(encryptPayload, linkCodePairingExpanded, encryptIv, Buffer.alloc(0)); const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted]); const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey); const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]); authState.creds.advSecretKey = Buffer.from(hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64'); await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', id: sock.generateMessageTag(), xmlns: 'md' }, content: [ { tag: 'link_code_companion_reg', attrs: { jid: authState.creds.me.id, stage: 'companion_finish' }, content: [ { tag: 'link_code_pairing_wrapped_key_bundle', attrs: {}, content: encryptedPayload }, { tag: 'companion_identity_public', attrs: {}, content: authState.creds.signedIdentityKey.public }, { tag: 'link_code_pairing_ref', attrs: {}, content: ref } ] } ] }); authState.creds.registered = true; ev.emit('creds.update', authState.creds); break; case 'privacy_token': await handlePrivacyTokenNotification(node); break; } if (Object.keys(result).length) { return result; } }; /** * In-memory cache of storage JIDs with stored tctokens, seeded from the persisted index. * Used to coalesce writes during a session; pruning always re-reads the persisted index * to cover writes made by other layers (e.g. history sync). */ const tcTokenKnownJids = new Set(); const tcTokenIndexLoaded = (async () => { try { const jids = await readTcTokenIndex(authState.keys); for (const jid of jids) tcTokenKnownJids.add(jid); logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index'); } catch (err) { logger.warn({ err: err?.message }, 'failed to load tctoken index'); } })(); let tcTokenIndexTimer; async function flushTcTokenIndex() { if (tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); tcTokenIndexTimer = undefined; } // Merge with whatever is already persisted so we don't clobber writes from other // paths (history sync, concurrent sessions on the same store). const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids); return authState.keys.set({ tctoken: write }); } function scheduleTcTokenIndexSave() { if (tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); } tcTokenIndexTimer = setTimeout(() => { tcTokenIndexTimer = undefined; flushTcTokenIndex().catch(err => { logger.warn({ err: err?.message }, 'failed to save tctoken index'); }); }, 5000); } function trackTcTokenJid(jid) { if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) { tcTokenKnownJids.add(jid); scheduleTcTokenIndexSave(); } } const handlePrivacyTokenNotification = async (node) => { const tokensNode = getBinaryNodeChild(node, 'tokens'); if (!tokensNode) return; const from = jidNormalizedUser(node.attrs.from); // WA Web uses: senderLid ?? toLid(from) for the storage key // The sender_lid attribute provides the LID directly when available const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid)) ? jidNormalizedUser(node.attrs.sender_lid) : undefined; const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN)); logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification'); await storeTcTokensFromIqResult({ result: node, fallbackJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid }); }; async function decipherLinkPublicKey(data) { const buffer = toRequiredBuffer(data); const salt = buffer.slice(0, 32); const secretKey = await derivePairingCodeKey(authState.creds.pairingCode, salt); const iv = buffer.slice(32, 48); const payload = buffer.slice(48, 80); return aesDecryptCTR(payload, secretKey, iv); } function toRequiredBuffer(data) { if (data === undefined) { throw new Boom('Invalid buffer', { statusCode: 400 }); } return data instanceof Buffer ? data : Buffer.from(data); } const willSendMessageAgain = async (id, participant) => { const key = `${id}:${participant}`; const retryCount = (await msgRetryCache.get(key