fix(whatsapp): complete history synchronization

This commit is contained in:
wmq
2026-08-01 20:23:17 +08:00
parent cf25d71fcf
commit 785a5b196c
5 changed files with 271 additions and 18 deletions
+11 -1
View File
@@ -15,7 +15,8 @@
"https-proxy-agent": "^7.0.6",
"multer": "2.2.0",
"pino": "9.6.0",
"qrcode": "1.5.4"
"qrcode": "1.5.4",
"undici": "^6.21.3"
}
},
"node_modules/@borewit/text-codec": {
@@ -2624,6 +2625,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmmirror.com/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
+2 -1
View File
@@ -17,6 +17,7 @@
"https-proxy-agent": "^7.0.6",
"multer": "2.2.0",
"pino": "9.6.0",
"qrcode": "1.5.4"
"qrcode": "1.5.4",
"undici": "^6.21.3"
}
}
+110 -3
View File
@@ -9,10 +9,12 @@ import path from 'node:path'
import https from 'node:https'
import { Boom } from '@hapi/boom'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { ProxyAgent as UndiciProxyAgent } from 'undici'
import makeWASocket, {
Browsers,
BufferJSON,
DisconnectReason,
downloadAndProcessHistorySyncNotification,
downloadMediaMessage,
fetchLatestBaileysVersion,
getContentType,
@@ -308,6 +310,61 @@ function upsertMessage(accountId, raw) {
return message
}
async function replayStoredHistoryNotifications(
accountId, account, store, logger, processedIds = new Set(), downloadOptions = {},
) {
const replayed = new Set(account.replayedHistoryNotificationIds || [])
const candidates = []
for (const messages of store.messages.values()) {
for (const item of messages || []) {
const notification = item.raw?.message?.protocolMessage?.historySyncNotification
if (!notification || !item.id || replayed.has(item.id) || processedIds.has(item.id)) continue
candidates.push({ id: item.id, notification })
}
}
for (const candidate of candidates) {
try {
const data = await downloadAndProcessHistorySyncNotification(
candidate.notification, downloadOptions, logger,
)
for (const chat of data.chats || []) {
const id = String(chat.id || '')
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
}
for (const contact of data.contacts || []) {
const id = String(contact.id || '')
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
}
for (const message of data.messages || []) upsertMessage(accountId, message)
replayed.add(candidate.id)
account.replayedHistoryNotificationIds = [...replayed].slice(-100)
account.historySync = {
status: 'processing',
syncType: data.syncType ?? candidate.notification.syncType ?? null,
progress: data.progress ?? candidate.notification.progress ?? null,
chats: data.chats?.length || 0,
contacts: data.contacts?.length || 0,
messages: data.messages?.length || 0,
recovered: true,
updatedAt: new Date().toISOString(),
}
await saveAccounts()
scheduleStoreSave(accountId)
void deliverWebhook(account, 'history.synced', {
chats: data.chats?.length || 0,
contacts: data.contacts?.length || 0,
messages: data.messages?.length || 0,
progress: data.progress ?? candidate.notification.progress ?? null,
syncType: data.syncType ?? candidate.notification.syncType ?? null,
recovered: true,
isLatest: true,
})
} catch (error) {
logger.warn({ err: error, messageId: candidate.id }, 'stored history notification replay failed')
}
}
}
function storedMessage(accountId, jid, messageId) {
const store = stores.get(accountId)
return (store?.messages.get(jid) || []).find((item) => item.id === messageId)
@@ -672,10 +729,13 @@ async function connectAccount(accountId) {
const authPath = path.join(authDir, accountId)
await fs.mkdir(authPath, { recursive: true, mode: 0o700 })
const { state, saveCreds } = await useMultiFileAuthState(authPath)
const { version } = await fetchLatestBaileysVersion()
const logger = log.child({ accountId })
const proxyUrl = accountProxyUrl(account)
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
const fetchDispatcher = proxyUrl ? new UndiciProxyAgent(proxyUrl) : undefined
const { version } = await fetchLatestBaileysVersion(
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
)
const sock = makeWASocket({
version,
logger,
@@ -685,7 +745,11 @@ async function connectAccount(accountId) {
},
browser: Browsers.ubuntu('Lingxi Crossborder'),
...(proxyAgent ? { agent: proxyAgent, fetchAgent: proxyAgent } : {}),
...(fetchDispatcher ? { options: { dispatcher: fetchDispatcher } } : {}),
syncFullHistory: true,
// Baileys 7 excludes FULL history notifications by default even when
// syncFullHistory is enabled. Accept every server-provided history phase.
shouldSyncHistoryMessage: () => true,
markOnlineOnConnect: false,
generateHighQualityLinkPreview: true,
getMessage: async (key) => {
@@ -694,7 +758,7 @@ async function connectAccount(accountId) {
},
cachedGroupMetadata: async (jid) => store.groups.get(jid),
})
sessions.set(accountId, { sock, connecting: true })
sessions.set(accountId, { sock, connecting: true, fetchDispatcher })
account.status = state.creds.registered ? 'connecting' : 'waiting_pairing'
account.updatedAt = new Date().toISOString()
await saveAccounts()
@@ -722,6 +786,18 @@ async function connectAccount(accountId) {
const session = sessions.get(accountId)
if (session) session.connecting = false
await saveAccounts()
const processedIds = new Set(
(state.creds.processedHistoryMessages || []).map((item) => String(item?.key?.id || '')),
)
void replayStoredHistoryNotifications(
accountId,
account,
store,
logger,
processedIds,
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
)
.catch((error) => logger.warn({ err: error }, 'stored history recovery failed'))
for (const job of outboundJobs.values()) {
if (job.accountId === accountId && ['queued', 'retrying', 'sending'].includes(job.status)) {
scheduleOutboundJob(job.id)
@@ -733,7 +809,9 @@ async function connectAccount(accountId) {
if (sessions.get(accountId)?.sock !== sock) return
const statusCode = new Boom(update.lastDisconnect?.error).output?.statusCode
const loggedOut = statusCode === DisconnectReason.loggedOut
const session = sessions.get(accountId)
sessions.delete(accountId)
if (session?.fetchDispatcher) void session.fetchDispatcher.close().catch(() => undefined)
account.status = loggedOut ? 'logged_out' : 'disconnected'
account.connectionState = loggedOut ? 'logged_out' : 'reconnecting'
account.lastError = update.lastDisconnect?.error?.message || `连接关闭 (${statusCode || 'unknown'})`
@@ -753,7 +831,9 @@ async function connectAccount(accountId) {
}
}
})
sock.ev.on('messaging-history.set', ({ chats = [], contacts = [], messages = [] }) => {
sock.ev.on('messaging-history.set', ({
chats = [], contacts = [], messages = [], isLatest, progress, syncType, chunkOrder,
}) => {
for (const chat of chats) {
const id = String(chat.id || '')
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
@@ -763,13 +843,40 @@ async function connectAccount(accountId) {
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
}
for (const item of messages) upsertMessage(accountId, item)
account.historySync = {
status: isLatest || progress === 100 ? 'processing' : 'syncing',
syncType: syncType ?? null,
progress: progress ?? null,
chunkOrder: chunkOrder ?? null,
chats: chats.length,
contacts: contacts.length,
messages: messages.length,
updatedAt: new Date().toISOString(),
}
void saveAccounts()
scheduleStoreSave(accountId)
void deliverWebhook(account, 'history.synced', {
chats: chats.length,
contacts: contacts.length,
messages: messages.length,
isLatest: Boolean(isLatest),
progress: progress ?? null,
syncType: syncType ?? null,
chunkOrder: chunkOrder ?? null,
})
})
sock.ev.on('messaging-history.status', ({ syncType, status, explicit }) => {
account.historySync = {
...(account.historySync || {}),
status,
syncType,
explicit: Boolean(explicit),
updatedAt: new Date().toISOString(),
...(status === 'complete' ? { completedAt: new Date().toISOString() } : {}),
}
void saveAccounts()
void deliverWebhook(account, 'history.status', account.historySync)
})
sock.ev.on('chats.upsert', (items) => {
for (const chat of items || []) {
const id = String(chat.id || '')