UNPKG

@whiskeysockets/baileys

Version:

A WebSockets library for interacting with WhatsApp Web

1,193 lines 48.9 kB
import NodeCache from '@cacheable/node-cache'; import { Boom } from '@hapi/boom'; import { proto } from '../../WAProto/index.js'; import { DEFAULT_CACHE_TTLS, HISTORY_SYNC_PAUSED_TIMEOUT_MS, PROCESSABLE_HISTORY_TYPES } from '../Defaults/index.js'; import { ALL_WA_PATCH_NAMES } from '../Types/index.js'; import { SyncState } from '../Types/State.js'; import { chatModificationToAppPatch, decodePatches, decodeSyncdSnapshot, encodeSyncdPatch, ensureLTHashStateVersion, extractSyncdPatches, generateProfilePicture, getHistoryMsg, isAppStateSyncIrrecoverable, isMissingKeyError, MAX_SYNC_ATTEMPTS, newLTHashState, processSyncAction } from '../Utils/index.js'; import { makeMutex } from '../Utils/make-mutex.js'; import processMessage from '../Utils/process-message.js'; import { buildTcTokenFromJid } from '../Utils/tc-token-utils.js'; import { getBinaryNodeChild, getBinaryNodeChildren, isHostedLidUser, isHostedPnUser, isLidUser, isPnUser, jidDecode, jidNormalizedUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary/index.js'; import { USyncQuery, USyncUser } from '../WAUSync/index.js'; import { makeSocket } from './socket.js'; export const makeChatsSocket = (config) => { const { logger, markOnlineOnConnect, fireInitQueries, appStateMacVerification, shouldIgnoreJid, shouldSyncHistoryMessage, getMessage } = config; const sock = makeSocket(config); const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError, sendUnifiedSession, registerSocketEndHandler } = sock; const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping); let privacySettings; /** Server-assigned AB props for protocol behavior. */ const serverProps = { /** AB prop 10518: gate tctoken on 1:1 messages. Default true (safe: avoids 463). */ privacyTokenOn1to1: true, /** AB prop 9666: gate tctoken on profile picture IQs. WA Web default: true. */ profilePicPrivacyToken: true, /** AB prop 14303: issue tctokens to LID instead of PN. WA Web default: false. */ lidTrustedTokenIssueToLid: false }; let syncState = SyncState.Connecting; /** this mutex ensures that messages are processed in order */ const messageMutex = makeMutex(); /** this mutex ensures that receipts are processed in order */ const receiptMutex = makeMutex(); /** 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(); // Timeout for AwaitingInitialSync state let awaitingSyncTimeout; // In-memory history sync completion tracking (resets on reconnection) const historySyncStatus = { initialBootstrapComplete: false, recentSyncComplete: false }; let historySyncPausedTimeout; // Collections blocked on missing app state sync keys (mirrors WA Web's "Blocked" state). // When a key arrives via APP_STATE_SYNC_KEY_SHARE, these are re-synced. const blockedCollections = new Set(); const placeholderResendCache = config.placeholderResendCache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour useClones: false }); /** helper function to fetch the given app state sync key */ const getAppStateSyncKey = async (keyId) => { const { [keyId]: key } = await authState.keys.get('app-state-sync-key', [keyId]); return key; }; const fetchPrivacySettings = async (force = false) => { if (!privacySettings || force) { const { content } = await query({ tag: 'iq', attrs: { xmlns: 'privacy', to: S_WHATSAPP_NET, type: 'get' }, content: [{ tag: 'privacy', attrs: {} }] }); privacySettings = reduceBinaryNodeToDictionary(content?.[0], 'category'); } return privacySettings; }; /** helper function to run a privacy IQ query */ const privacyQuery = async (name, value) => { await query({ tag: 'iq', attrs: { xmlns: 'privacy', to: S_WHATSAPP_NET, type: 'set' }, content: [ { tag: 'privacy', attrs: {}, content: [ { tag: 'category', attrs: { name, value } } ] } ] }); }; const updateMessagesPrivacy = async (value) => { await privacyQuery('messages', value); }; const updateCallPrivacy = async (value) => { await privacyQuery('calladd', value); }; const updateLastSeenPrivacy = async (value) => { await privacyQuery('last', value); }; const updateOnlinePrivacy = async (value) => { await privacyQuery('online', value); }; const updateProfilePicturePrivacy = async (value) => { await privacyQuery('profile', value); }; const updateStatusPrivacy = async (value) => { await privacyQuery('status', value); }; const updateReadReceiptsPrivacy = async (value) => { await privacyQuery('readreceipts', value); }; const updateGroupsAddPrivacy = async (value) => { await privacyQuery('groupadd', value); }; const updateDefaultDisappearingMode = async (duration) => { await query({ tag: 'iq', attrs: { xmlns: 'disappearing_mode', to: S_WHATSAPP_NET, type: 'set' }, content: [ { tag: 'disappearing_mode', attrs: { duration: duration.toString() } } ] }); }; const getBotListV2 = async () => { const resp = await query({ tag: 'iq', attrs: { xmlns: 'bot', to: S_WHATSAPP_NET, type: 'get' }, content: [ { tag: 'bot', attrs: { v: '2' } } ] }); const botNode = getBinaryNodeChild(resp, 'bot'); const botList = []; for (const section of getBinaryNodeChildren(botNode, 'section')) { if (section.attrs.type === 'all') { for (const bot of getBinaryNodeChildren(section, 'bot')) { botList.push({ jid: bot.attrs.jid, personaId: bot.attrs['persona_id'] }); } } } return botList; }; const fetchStatus = async (...jids) => { const usyncQuery = new USyncQuery().withStatusProtocol(); for (const jid of jids) { usyncQuery.withUser(new USyncUser().withId(jid)); } const result = await sock.executeUSyncQuery(usyncQuery); if (result) { return result.list; } }; const fetchDisappearingDuration = async (...jids) => { const usyncQuery = new USyncQuery().withDisappearingModeProtocol(); for (const jid of jids) { usyncQuery.withUser(new USyncUser().withId(jid)); } const result = await sock.executeUSyncQuery(usyncQuery); if (result) { return result.list; } }; /** update the profile picture for yourself or a group */ const updateProfilePicture = async (jid, content, dimensions) => { let targetJid; if (!jid) { throw new Boom('Illegal no-jid profile update. Please specify either your ID or the ID of the chat you wish to update'); } if (jidNormalizedUser(jid) !== jidNormalizedUser(authState.creds.me.id)) { targetJid = jidNormalizedUser(jid); // in case it is someone other than us } else { targetJid = undefined; } const { img } = await generateProfilePicture(content, dimensions); await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'w:profile:picture', ...(targetJid ? { target: targetJid } : {}) }, content: [ { tag: 'picture', attrs: { type: 'image' }, content: img } ] }); }; /** remove the profile picture for yourself or a group */ const removeProfilePicture = async (jid) => { let targetJid; if (!jid) { throw new Boom('Illegal no-jid profile update. Please specify either your ID or the ID of the chat you wish to update'); } if (jidNormalizedUser(jid) !== jidNormalizedUser(authState.creds.me.id)) { targetJid = jidNormalizedUser(jid); // in case it is someone other than us } else { targetJid = undefined; } await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'w:profile:picture', ...(targetJid ? { target: targetJid } : {}) } }); }; /** update the profile status for yourself */ const updateProfileStatus = async (status) => { await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'status' }, content: [ { tag: 'status', attrs: {}, content: Buffer.from(status, 'utf-8') } ] }); }; const updateProfileName = async (name) => { await chatModify({ pushNameSetting: name }, ''); }; const fetchBlocklist = async () => { const result = await query({ tag: 'iq', attrs: { xmlns: 'blocklist', to: S_WHATSAPP_NET, type: 'get' } }); const listNode = getBinaryNodeChild(result, 'list'); return getBinaryNodeChildren(listNode, 'item').map(n => n.attrs.jid); }; const updateBlockStatus = async (jid, action) => { const normalizedJid = jidNormalizedUser(jid); let lid; let pn_jid; if (isLidUser(normalizedJid) || isHostedLidUser(normalizedJid)) { lid = normalizedJid; if (action === 'block') { const pn = await signalRepository.lidMapping.getPNForLID(normalizedJid); if (!pn) { throw new Boom(`Unable to resolve PN JID for LID: ${jid}`, { statusCode: 400 }); } pn_jid = jidNormalizedUser(pn); } } else if (isPnUser(normalizedJid) || isHostedPnUser(normalizedJid)) { const mapped = await signalRepository.lidMapping.getLIDForPN(normalizedJid); if (!mapped) { throw new Boom(`Unable to resolve LID for PN JID: ${jid}`, { statusCode: 400 }); } lid = mapped; if (action === 'block') { pn_jid = jidNormalizedUser(normalizedJid); } } else { throw new Boom(`Invalid jid: ${jid}`, { statusCode: 400 }); } const itemAttrs = { action, jid: lid }; if (action === 'block') { if (!pn_jid) { throw new Boom(`pn_jid required for block: ${jid}`, { statusCode: 400 }); } itemAttrs.pn_jid = pn_jid; } await query({ tag: 'iq', attrs: { xmlns: 'blocklist', to: S_WHATSAPP_NET, type: 'set' }, content: [ { tag: 'item', attrs: itemAttrs } ] }); }; const getBusinessProfile = async (jid) => { const results = await query({ tag: 'iq', attrs: { to: 's.whatsapp.net', xmlns: 'w:biz', type: 'get' }, content: [ { tag: 'business_profile', attrs: { v: '244' }, content: [ { tag: 'profile', attrs: { jid } } ] } ] }); const profileNode = getBinaryNodeChild(results, 'business_profile'); const profiles = getBinaryNodeChild(profileNode, 'profile'); if (profiles) { const address = getBinaryNodeChild(profiles, 'address'); const description = getBinaryNodeChild(profiles, 'description'); const website = getBinaryNodeChild(profiles, 'website'); const email = getBinaryNodeChild(profiles, 'email'); const category = getBinaryNodeChild(getBinaryNodeChild(profiles, 'categories'), 'category'); const businessHours = getBinaryNodeChild(profiles, 'business_hours'); const businessHoursConfig = businessHours ? getBinaryNodeChildren(businessHours, 'business_hours_config') : undefined; const websiteStr = website?.content?.toString(); return { wid: profiles.attrs?.jid, address: address?.content?.toString(), description: description?.content?.toString() || '', website: websiteStr ? [websiteStr] : [], email: email?.content?.toString(), category: category?.content?.toString(), business_hours: { timezone: businessHours?.attrs?.timezone, business_config: businessHoursConfig?.map(({ attrs }) => attrs) } }; } }; const cleanDirtyBits = async (type, fromTimestamp) => { logger.info({ fromTimestamp }, 'clean dirty bits ' + type); await sendNode({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'urn:xmpp:whatsapp:dirty', id: generateMessageTag() }, content: [ { tag: 'clean', attrs: { type, ...(fromTimestamp ? { timestamp: fromTimestamp.toString() } : null) } } ] }); }; const newAppStateChunkHandler = (isInitialSync) => { return { onMutation(mutation) { processSyncAction(mutation, ev, authState.creds.me, isInitialSync ? { accountSettings: authState.creds.accountSettings } : undefined, logger); } }; }; const resyncAppState = ev.createBufferedFunction(async (collections, isInitialSync) => { const appStateSyncKeyCache = new Map(); const getCachedAppStateSyncKey = async (keyId) => { if (appStateSyncKeyCache.has(keyId)) { return appStateSyncKeyCache.get(keyId) ?? undefined; } const key = await getAppStateSyncKey(keyId); appStateSyncKeyCache.set(keyId, key ?? null); return key; }; // we use this to determine which events to fire // otherwise when we resync from scratch -- all notifications will fire const initialVersionMap = {}; const globalMutationMap = {}; await authState.keys.transaction(async () => { const collectionsToHandle = new Set(collections); // in case something goes wrong -- ensure we don't enter a loop that cannot be exited from const attemptsMap = {}; // collections that failed and need a full snapshot on retry // mirrors WA Web's ErrorFatal -> force snapshot behavior const forceSnapshotCollections = new Set(); // keep executing till all collections are done // sometimes a single patch request will not return all the patches (God knows why) // so we fetch till they're all done (this is determined by the "has_more_patches" flag) while (collectionsToHandle.size) { const states = {}; const nodes = []; for (const name of collectionsToHandle) { const result = await authState.keys.get('app-state-sync-version', [name]); let state = result[name]; if (state) { state = ensureLTHashStateVersion(state); if (typeof initialVersionMap[name] === 'undefined') { initialVersionMap[name] = state.version; } } else { state = newLTHashState(); } states[name] = state; const shouldForceSnapshot = forceSnapshotCollections.has(name); if (shouldForceSnapshot) { forceSnapshotCollections.delete(name); } logger.info(`resyncing ${name} from v${state.version}${shouldForceSnapshot ? ' (forcing snapshot)' : ''}`); nodes.push({ tag: 'collection', attrs: { name, version: state.version.toString(), // return snapshot if syncing from scratch or forcing after a failed attempt return_snapshot: (shouldForceSnapshot || !state.version).toString() } }); } const result = await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, xmlns: 'w:sync:app:state', type: 'set' }, content: [ { tag: 'sync', attrs: {}, content: nodes } ] }); // extract from binary node const decoded = await extractSyncdPatches(result, config?.options); for (const key in decoded) { const name = key; const { patches, hasMorePatches, snapshot } = decoded[name]; try { if (snapshot) { const { state: newState, mutationMap } = await decodeSyncdSnapshot(name, snapshot, getCachedAppStateSyncKey, initialVersionMap[name], appStateMacVerification.snapshot, logger); states[name] = newState; Object.assign(globalMutationMap, mutationMap); logger.info(`restored state of ${name} from snapshot to v${newState.version} with mutations`); await authState.keys.set({ 'app-state-sync-version': { [name]: newState } }); } // only process if there are syncd patches if (patches.length) { const { state: newState, mutationMap } = await decodePatches(name, patches, states[name], getCachedAppStateSyncKey, config.options, initialVersionMap[name], logger, appStateMacVerification.patch); await authState.keys.set({ 'app-state-sync-version': { [name]: newState } }); logger.info(`synced ${name} to v${newState.version}`); initialVersionMap[name] = newState.version; Object.assign(globalMutationMap, mutationMap); } if (hasMorePatches) { logger.info(`${name} has more patches...`); } else { // collection is done with sync collectionsToHandle.delete(name); } } catch (error) { attemptsMap[name] = (attemptsMap[name] || 0) + 1; const logData = { name, attempt: attemptsMap[name], version: states[name].version, statusCode: error.output?.statusCode, errorType: error.name, error: error.stack }; if (isMissingKeyError(error) && attemptsMap[name] >= MAX_SYNC_ATTEMPTS) { // WA Web treats missing keys as "Blocked" — park the collection // until the key arrives via APP_STATE_SYNC_KEY_SHARE. logger.warn(logData, `${name} blocked on missing key from v${states[name].version}, parking after ${attemptsMap[name]} attempts`); blockedCollections.add(name); collectionsToHandle.delete(name); } else if (isMissingKeyError(error)) { // Retry with a snapshot which may use a different key. logger.info(logData, `${name} blocked on missing key from v${states[name].version}, retrying with snapshot`); forceSnapshotCollections.add(name); } else if (isAppStateSyncIrrecoverable(error, attemptsMap[name])) { logger.warn(logData, `failed to sync ${name} from v${states[name].version}, giving up`); collectionsToHandle.delete(name); } else { logger.info(logData, `failed to sync ${name} from v${states[name].version}, forcing snapshot retry`); // force a full snapshot on retry to recover from // corrupted local state (e.g. LTHash MAC mismatch) forceSnapshotCollections.add(name); } } } } }, authState?.creds?.me?.id || 'resync-app-state'); const { onMutation } = newAppStateChunkHandler(isInitialSync); for (const key in globalMutationMap) { onMutation(globalMutationMap[key]); } }); /** * fetch the profile picture of a user/group * type = "preview" for a low res picture * type = "image for the high res picture" */ const profilePictureUrl = async (jid, type = 'preview', timeoutMs) => { const baseContent = [{ tag: 'picture', attrs: { type, query: 'url' } }]; // WA Web only includes tctoken for user JIDs (not groups/newsletters) // and never for own profile pic (Chat model for self has no tcToken). // Including tctoken for own JID causes the server to never respond. const normalizedJid = jidNormalizedUser(jid); const isUserJid = isPnUser(normalizedJid) || isLidUser(normalizedJid); const me = authState.creds.me; const isSelf = me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid))); let content = baseContent; if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) { content = await buildTcTokenFromJid({ authState, jid: normalizedJid, baseContent, getLIDForPN }); } jid = jidNormalizedUser(jid); const result = await query({ tag: 'iq', attrs: { target: jid, to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:profile:picture' }, content }, timeoutMs); const child = getBinaryNodeChild(result, 'picture'); return child?.attrs?.url; }; const createCallLink = async (type, event, timeoutMs) => { const result = await query({ tag: 'call', attrs: { id: generateMessageTag(), to: '@call' }, content: [ { tag: 'link_create', attrs: { media: type }, content: event ? [{ tag: 'event', attrs: { start_time: String(event.startTime) } }] : undefined } ] }, timeoutMs); const child = getBinaryNodeChild(result, 'link_create'); return child?.attrs?.token; }; const sendPresenceUpdate = async (type, toJid) => { const me = authState.creds.me; const isAvailableType = type === 'available'; if (isAvailableType || type === 'unavailable') { if (!me.name) { logger.warn('no name present, ignoring presence update request...'); return; } ev.emit('connection.update', { isOnline: isAvailableType }); if (isAvailableType) { void sendUnifiedSession(); } await sendNode({ tag: 'presence', attrs: { name: me.name.replace(/@/g, ''), type } }); } else { const { server } = jidDecode(toJid); const isLid = server === 'lid'; await sendNode({ tag: 'chatstate', attrs: { from: isLid ? me.lid : me.id, to: toJid }, content: [ { tag: type === 'recording' ? 'composing' : type, attrs: type === 'recording' ? { media: 'audio' } : {} } ] }); } }; /** * @param toJid the jid to subscribe to * @param tcToken token for subscription, use if present */ const presenceSubscribe = async (toJid) => { // Only include tctoken for user JIDs — groups/newsletters don't use tctokens const normalizedToJid = jidNormalizedUser(toJid); const isUserJid = isPnUser(normalizedToJid) || isLidUser(normalizedToJid); const tcTokenContent = isUserJid ? await buildTcTokenFromJid({ authState, jid: normalizedToJid, getLIDForPN }) : undefined; return sendNode({ tag: 'presence', attrs: { to: toJid, id: generateMessageTag(), type: 'subscribe' }, content: tcTokenContent }); }; const handlePresenceUpdate = ({ tag, attrs, content }) => { let presence; const jid = attrs.from; const participant = attrs.participant || attrs.from; if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) { return; } if (tag === 'presence') { presence = { lastKnownPresence: attrs.type === 'unavailable' ? 'unavailable' : 'available', lastSeen: attrs.last && attrs.last !== 'deny' ? +attrs.last : undefined, groupOnlineCount: attrs.count ? +attrs.count : undefined }; } else if (Array.isArray(content)) { const [firstChild] = content; let type = firstChild.tag; if (type === 'paused') { type = 'available'; } if (firstChild.attrs?.media === 'audio') { type = 'recording'; } presence = { lastKnownPresence: type }; } else { logger.error({ tag, attrs, content }, 'recv invalid presence node'); } if (presence) { ev.emit('presence.update', { id: jid, presences: { [participant]: presence } }); } }; const appPatch = async (patchCreate) => { const name = patchCreate.type; const myAppStateKeyId = authState.creds.myAppStateKeyId; if (!myAppStateKeyId) { throw new Boom('App state key not present!', { statusCode: 400 }); } let initial; let encodeResult; await appStatePatchMutex.mutex(async () => { await authState.keys.transaction(async () => { logger.debug({ patch: patchCreate }, 'applying app patch'); await resyncAppState([name], false); const { [name]: currentSyncVersion } = await authState.keys.get('app-state-sync-version', [name]); initial = currentSyncVersion ? ensureLTHashStateVersion(currentSyncVersion) : newLTHashState(); encodeResult = await encodeSyncdPatch(patchCreate, myAppStateKeyId, initial, getAppStateSyncKey); const { patch, state } = encodeResult; const node = { tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', xmlns: 'w:sync:app:state' }, content: [ { tag: 'sync', attrs: {}, content: [ { tag: 'collection', attrs: { name, version: (state.version - 1).toString(), return_snapshot: 'false' }, content: [ { tag: 'patch', attrs: {}, content: proto.SyncdPatch.encode(patch).finish() } ] } ] } ] }; await query(node); await authState.keys.set({ 'app-state-sync-version': { [name]: state } }); }, authState?.creds?.me?.id || 'app-patch'); }); if (config.emitOwnEvents) { const { onMutation } = newAppStateChunkHandler(false); const { mutationMap } = await decodePatches(name, [{ ...encodeResult.patch, version: { version: encodeResult.state.version } }], initial, getAppStateSyncKey, config.options, undefined, logger); for (const key in mutationMap) { onMutation(mutationMap[key]); } } }; /** fetch AB props */ const fetchProps = async () => { const resultNode = await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, xmlns: 'abt', type: 'get' }, content: [ { tag: 'props', attrs: { protocol: '1', ...(authState?.creds?.lastPropHash ? { hash: authState.creds.lastPropHash } : {}) } } ] }); const propsNode = getBinaryNodeChild(resultNode, 'props'); let props = {}; if (propsNode) { if (propsNode.attrs?.hash) { // on some clients, the hash is returning as undefined authState.creds.lastPropHash = propsNode?.attrs?.hash; ev.emit('creds.update', authState.creds); } props = reduceBinaryNodeToDictionary(propsNode, 'prop'); } // Extract protocol-relevant AB props (only the ones we need) const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']; if (privacyTokenProp !== undefined) { serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'; } const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']; if (profilePicProp !== undefined) { serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'; } const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']; if (lidIssueProp !== undefined) { serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'; } logger.debug({ serverProps }, 'fetched props'); return props; }; /** * modify a chat -- mark unread, read etc. * lastMessages must be sorted in reverse chronologically * requires the last messages till the last message received; required for archive & unread */ const chatModify = (mod, jid) => { const patch = chatModificationToAppPatch(mod, jid); return appPatch(patch); }; /** * Enable/Disable link preview privacy, not related to baileys link preview generation */ const updateDisableLinkPreviewsPrivacy = (isPreviewsDisabled) => { return chatModify({ disableLinkPreviews: { isPreviewsDisabled } }, ''); }; /** * Star or Unstar a message */ const star = (jid, messages, star) => { return chatModify({ star: { messages, star } }, jid); }; /** * Add or Edit Contact */ const addOrEditContact = (jid, contact) => { return chatModify({ contact }, jid); }; /** * Remove Contact */ const removeContact = (jid) => { return chatModify({ contact: null }, jid); }; /** * Adds label */ const addLabel = (jid, labels) => { return chatModify({ addLabel: { ...labels } }, jid); }; /** * Adds label for the chats */ const addChatLabel = (jid, labelId) => { return chatModify({ addChatLabel: { labelId } }, jid); }; /** * Removes label for the chat */ const removeChatLabel = (jid, labelId) => { return chatModify({ removeChatLabel: { labelId } }, jid); }; /** * Adds label for the message */ const addMessageLabel = (jid, messageId, labelId) => { return chatModify({ addMessageLabel: { messageId, labelId } }, jid); }; /** * Removes label for the message */ const removeMessageLabel = (jid, messageId, labelId) => { return chatModify({ removeMessageLabel: { messageId, labelId } }, jid); }; /** * Add or Edit Quick Reply */ const addOrEditQuickReply = (quickReply) => { return chatModify({ quickReply }, ''); }; /** * Remove Quick Reply */ const removeQuickReply = (timestamp) => { return chatModify({ quickReply: { timestamp, deleted: true } }, ''); }; /** * queries need to be fired on connection open * help ensure parity with WA Web * */ const executeInitQueries = async () => { await Promise.all([fetchProps(), fetchBlocklist(), fetchPrivacySettings()]); }; const upsertMessage = ev.createBufferedFunction(async (msg, type) => { ev.emit('messages.upsert', { messages: [msg], type }); if (!!msg.pushName) { let jid = msg.key.fromMe ? authState.creds.me.id : msg.key.participant || msg.key.remoteJid; jid = jidNormalizedUser(jid); if (!msg.key.fromMe) { ev.emit('contacts.update', [{ id: jid, notify: msg.pushName, verifiedName: msg.verifiedBizName }]); } // update our pushname too if (msg.key.fromMe && msg.pushName && authState.creds.me?.name !== msg.pushName) { ev.emit('creds.update', { me: { ...authState.creds.me, name: msg.pushName } }); } } const historyMsg = getHistoryMsg(msg.message); const shouldProcessHistoryMsg = historyMsg ? shouldSyncHistoryMessage(historyMsg) && PROCESSABLE_HISTORY_TYPES.includes(historyMsg.syncType) : false; if (historyMsg && shouldProcessHistoryMsg) { const syncType = historyMsg.syncType; // INITIAL_BOOTSTRAP — fire immediately, no progress check (same as WA Web K function) if (syncType === proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP && !historySyncStatus.initialBootstrapComplete) { historySyncStatus.initialBootstrapComplete = true; ev.emit('messaging-history.status', { syncType, status: 'complete', explicit: true }); } // RECENT with progress === 100 — explicit completion if (syncType === proto.HistorySync.HistorySyncType.RECENT && historyMsg.progress === 100 && !historySyncStatus.recentSyncComplete) { historySyncStatus.recentSyncComplete = true; clearTimeout(historySyncPausedTimeout); historySyncPausedTimeout = undefined; ev.emit('messaging-history.status', { syncType, status: 'complete', explicit: true }); } // Reset 120s paused timeout on any RECENT chunk (like WA Web's handleChunkProgress) if (syncType === proto.HistorySync.HistorySyncType.RECENT && !historySyncStatus.recentSyncComplete) { clearTimeout(historySyncPausedTimeout); historySyncPausedTimeout = setTimeout(() => { if (!historySyncStatus.recentSyncComplete) { historySyncStatus.recentSyncComplete = true; ev.emit('messaging-history.status', { syncType: proto.HistorySync.HistorySyncType.RECENT, status: 'paused', explicit: false }); } historySyncPausedTimeout = undefined; }, HISTORY_SYNC_PAUSED_TIMEOUT_MS); } } // State machine: decide on sync and flush if (historyMsg && syncState === SyncState.AwaitingInitialSync) { if (awaitingSyncTimeout) { clearTimeout(awaitingSyncTimeout); awaitingSyncTimeout = undefined; } if (shouldProcessHistoryMsg) { syncState = SyncState.Syncing; logger.info('Transitioned to Syncing state'); // Let doAppStateSync handle the final flush after it's done } else { syncState = SyncState.Online; logger.info('History sync skipped, transitioning to Online state and flushing buffer'); ev.flush(); } } const doAppStateSync = async () => { if (syncState === SyncState.Syncing) { // All collections will be synced, so clear any blocked ones blockedCollections.clear(); logger.info('Doing app state sync'); await resyncAppState(ALL_WA_PATCH_NAMES, true); // Sync is complete, go online and flush everything syncState = SyncState.Online; logger.info('App state sync complete, transitioning to Online state and flushing buffer'); ev.flush(); const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1; ev.emit('creds.update', { accountSyncCounter }); } }; await Promise.all([ (async () => { if (shouldProcessHistoryMsg) { await doAppStateSync(); } })(), processMessage(msg, { signalRepository, shouldProcessHistoryMsg, placeholderResendCache, ev, creds: authState.creds, keyStore: authState.keys, logger, options: config.options, getMessage }) ]); // If the app state key arrives and we are waiting to sync, trigger the sync now. if (msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing) { logger.info('App state sync key arrived, triggering app state sync'); await doAppStateSync(); } }); ws.on('CB:presence', handlePresenceUpdate); ws.on('CB:chatstate', handlePresenceUpdate); ws.on('CB:ib,,dirty', async (node) => { const { attrs } = getBinaryNodeChild(node, 'dirty'); const type = attrs.type; switch (type) { case 'account_sync': if (attrs.timestamp) { let { lastAccountSyncTimestamp } = authState.creds; if (lastAccountSyncTimestamp) { await cleanDirtyBits('account_sync', lastAccountSyncTimestamp); } lastAccountSyncTimestamp = +attrs.timestamp; ev.emit('creds.update', { lastAccountSyncTimestamp }); } break; case 'groups': // handled in groups.ts break; default: logger.info({ node }, 'received unknown sync'); break; } }); ev.on('connection.update', ({ connection, receivedPendingNotifications }) => { if (connection === 'close') { blockedCollections.clear(); clearTimeout(historySyncPausedTimeout); historySyncPausedTimeout = undefined; } if (connection === 'open') { if (fireInitQueries) { executeInitQueries().catch(error => onUnexpectedError(error, 'init queries')); } sendPresenceUpdate(markOnlineOnConnect ? 'available' : 'unavailable').catch(error => onUnexpectedError(error, 'presence update requests')); } if (!receivedPendingNotifications || syncState !== SyncState.Connecting) { return; } historySyncStatus.initialBootstrapComplete = false; historySyncStatus.recentSyncComplete = false; clearTimeout(historySyncPausedTimeout); historySyncPausedTimeout = undefined; syncState = SyncState.AwaitingInitialSync; logger.info('Connection is now AwaitingInitialSync, buffering events'); ev.buffer(); const willSyncHistory = shouldSyncHistoryMessage(proto.Message.HistorySyncNotification.create({ syncType: proto.HistorySync.HistorySyncType.RECENT })); if (!willSyncHistory) { logger.info('History sync is disabled by config, not waiting for notification. Transitioning to Online.'); syncState = SyncState.Online; setTimeout(() => ev.flush(), 0); return; } // On reconnection (accountSyncCounter > 0), the server does not push // history sync notifications — the device already has its data. // Skip the 20s wait and go online immediately. if (authState.creds.accountSyncCounter > 0) { logger.info('Reconnection with existing sync data, skipping history sync wait. Transitioning to Online.'); syncState = SyncState.Online; setTimeout(() => ev.flush(), 0); return; } logger.info('First connection, awaiting history sync notification with a 20s timeout.'); if (awaitingSyncTimeout) { clearTimeout(awaitingSyncTimeout); } awaitingSyncTimeout = setTimeout(() => { if (syncState === SyncState.AwaitingInitialSync) { logger.warn('Timeout in AwaitingInitialSync, forcing state to Online and flushing buffer'); syncState = SyncState.Online; ev.flush(); // Increment so subsequent reconnections skip the 20s wait. // Late-arriving history is still processed via processMessage // regardless of the state machine phase. const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1; ev.emit('creds.update', { accountSyncCounter }); } }, 20000); }); // When an app state sync key arrives (myAppStateKeyId is set) and there are // collections blocked on a missing key, trigger a re-sync for just those collections. // This mirrors WA Web's Blocked → retry-on-key-arrival behavior. ev.on('creds.update', ({ myAppStateKeyId }) => { if (!myAppStateKeyId || blockedCollections.size === 0) { return; } // If we're in the middle of a full sync, doAppStateSync handles all collections if (syncState === SyncState.Syncing) { blockedCollections.clear(); return; } const collections = [...blockedCollections]; blockedCollections.clear(); logger.info({ collections }, 'app state sync key arrived, re-syncing blocked collections'); resyncAppState(collections, false).catch(error => onUnexpectedError(error, 'blocked collections resync')); }); ev.on('lid-mapping.update', async ({ lid, pn }) => { try { await signalRepository.lidMapping.storeLIDPNMappings([{ lid, pn }]); } catch (error) { logger.warn({ lid, pn, error }, 'Failed to store LID-PN mapping'); } }); registerSocketEndHandler(() => { if (awaitingSyncTimeout) { clearTimeout(awaitingSyncTimeout); awaitingSyncTimeout = undefined; } if (!config.placeholderResendCache && placeholderResendCache.close) { placeholderResendCache.close(); } syncState = SyncState.Connecting; privacySettings = undefined; }); return { ...sock, serverProps, createCallLink, getBotListV2, messageMutex, receiptMutex, appStatePatchMutex, notificationMutex, fetchPrivacySettings, upsertMessage, appPatch, sendPresenceUpdate, presenceSubscribe, profilePictureUrl, fetchBlocklist, fetchStatus, fetchDisappearingDuration, updateProfilePicture, removeProfilePicture, updateProfileStatus, updateProfileName, updateBlockStatus, updateDisableLinkPreviewsPrivacy, updateCallPrivacy, updateMessagesPrivacy, updateLastSeenPrivacy, updateOnlinePrivacy, updateProfilePicturePrivacy, updateStatusPrivacy, updateReadReceiptsPrivacy, updateGroupsAddPrivacy, updateDefaultDisappearingMode, getBusinessProfile, resyncAppState, chatModify, cleanDirtyBits, addOrEditContact, removeContact, placeholderResendCache, addLabel, addChatLabel, removeChatLabel, addMessageLabel, removeMessageLabel, star, addOrEditQuickReply, removeQuickReply }; }; //# sourceMappingURL=chats.js.map