6c52b01ea7
Two bugs fixed compared to prior implementation:
1. history.ts — normalize userJid LID→PN
pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
The consumer expects a phone number. After building the lidPnMap from conversations
and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
If the mapping is not available the original value is preserved (no data loss).
2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
Multiple HistorySync chunks can contain the same group. The previous approach
concatenated blindly, producing duplicate entries. Now a keyed map
{ [groupJid]: IPastParticipant[] } is used so each group appears once and
each participant within a group appears at most once.
Types updated (Events.ts):
- messaging-history.set event includes pastParticipants?: IPastParticipants[]
- BufferedEventData.historySets.pastParticipants typed as keyed map
1426 lines
41 KiB
TypeScript
1426 lines
41 KiB
TypeScript
import EventEmitter from 'events'
|
|
import { proto } from '../../WAProto/index.js'
|
|
import type {
|
|
BaileysEvent,
|
|
BaileysEventEmitter,
|
|
BaileysEventMap,
|
|
BufferedEventData,
|
|
Chat,
|
|
ChatUpdate,
|
|
Contact,
|
|
WAMessage,
|
|
WAMessageKey
|
|
} from '../Types'
|
|
import { WAMessageStatus } from '../Types'
|
|
import { logEventBuffer } from './baileys-logger'
|
|
import { trimUndefined } from './generics'
|
|
import type { ILogger } from './logger'
|
|
import { updateMessageWithReaction, updateMessageWithReceipt } from './messages'
|
|
import { isRealMessage, shouldIncrementChatUnread } from './process-message'
|
|
|
|
// ============================================================================
|
|
// BUFFER CONFIGURATION - Environment Variable Support
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Buffer configuration loaded from environment variables
|
|
* Allows runtime customization without code changes
|
|
*/
|
|
export interface BufferConfig {
|
|
/** Maximum buffer timeout in milliseconds */
|
|
bufferTimeoutMs: number
|
|
/** Minimum buffer timeout for adaptive algorithm */
|
|
minBufferTimeoutMs: number
|
|
/** Maximum buffer timeout for adaptive algorithm */
|
|
maxBufferTimeoutMs: number
|
|
/** Maximum history cache size before cleanup */
|
|
maxHistoryCacheSize: number
|
|
/** Maximum events before forcing a flush (overflow protection) */
|
|
maxBufferSize: number
|
|
/** Debounce delay for flush after buffered function */
|
|
flushDebounceMs: number
|
|
/** Enable adaptive timeout based on load */
|
|
enableAdaptiveTimeout: boolean
|
|
/** Enable Prometheus metrics */
|
|
enableMetrics: boolean
|
|
/** LRU cleanup percentage (0-1) when cache exceeds max */
|
|
lruCleanupRatio: number
|
|
/** Warn threshold for buffer size (percentage of max) */
|
|
bufferWarnThreshold: number
|
|
}
|
|
|
|
/**
|
|
* Load buffer configuration from environment variables
|
|
* Uses BAILEYS_BUFFER_* prefix for consistency
|
|
*/
|
|
export function loadBufferConfig(): BufferConfig {
|
|
return {
|
|
// 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 || '3000', 10),
|
|
minBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MIN_TIMEOUT_MS || '1000', 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 || '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'),
|
|
bufferWarnThreshold: parseFloat(process.env.BAILEYS_BUFFER_WARN_THRESHOLD || '0.8')
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// BUFFERABLE EVENTS
|
|
// ============================================================================
|
|
|
|
const BUFFERABLE_EVENT = [
|
|
'messaging-history.set',
|
|
'chats.upsert',
|
|
'chats.update',
|
|
'chats.delete',
|
|
'contacts.upsert',
|
|
'contacts.update',
|
|
'messages.upsert',
|
|
'messages.update',
|
|
'messages.delete',
|
|
'messages.reaction',
|
|
'message-receipt.update',
|
|
'groups.update',
|
|
'lid-mapping.update'
|
|
] as const
|
|
|
|
type BufferableEvent = (typeof BUFFERABLE_EVENT)[number]
|
|
|
|
/**
|
|
* A map that contains a list of all events that have been triggered
|
|
*
|
|
* Note, this can contain different type of events
|
|
* this can make processing events extremely efficient -- since everything
|
|
* can be done in a single transaction
|
|
*/
|
|
type BaileysEventData = Partial<BaileysEventMap>
|
|
|
|
const BUFFERABLE_EVENT_SET = new Set<BaileysEvent>(BUFFERABLE_EVENT)
|
|
|
|
// ============================================================================
|
|
// BUFFER STATISTICS - For Metrics and Monitoring
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Statistics about buffer operations for monitoring and debugging
|
|
*/
|
|
export interface BufferStatistics {
|
|
/** Total number of flushes performed */
|
|
totalFlushes: number
|
|
/** Total number of forced flushes (overflow/timeout) */
|
|
forcedFlushes: number
|
|
/** Total events buffered */
|
|
totalEventsBuffered: number
|
|
/** Current buffer size */
|
|
currentBufferSize: number
|
|
/** Peak buffer size reached */
|
|
peakBufferSize: number
|
|
/** Total buffer overflows prevented */
|
|
overflowsDetected: number
|
|
/** Current adaptive timeout */
|
|
currentTimeout: number
|
|
/** History cache size */
|
|
historyCacheSize: number
|
|
/** LRU cleanups performed */
|
|
lruCleanups: number
|
|
/** Average events per flush */
|
|
avgEventsPerFlush: number
|
|
/** Buffer creation timestamp */
|
|
createdAt: number
|
|
/** Last flush timestamp */
|
|
lastFlushAt: number | null
|
|
}
|
|
|
|
// ============================================================================
|
|
// EXTENDED EVENT EMITTER TYPE
|
|
// ============================================================================
|
|
|
|
type BaileysBufferableEventEmitter = BaileysEventEmitter & {
|
|
/** Use to process events in a batch */
|
|
process(handler: (events: BaileysEventData) => void | Promise<void>): () => void
|
|
/**
|
|
* starts buffering events, call flush() to release them
|
|
*/
|
|
buffer(): void
|
|
/** buffers all events till the promise completes */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createBufferedFunction<A extends any[], T>(work: (...args: A) => Promise<T>): (...args: A) => Promise<T>
|
|
/**
|
|
* flushes all buffered events
|
|
* @param force - If true, flush even if buffer is empty or not buffering
|
|
* @returns returns true if the flush actually happened, otherwise false
|
|
*/
|
|
flush(force?: boolean): boolean
|
|
/** is there an ongoing buffer */
|
|
isBuffering(): boolean
|
|
/**
|
|
* Destroy the buffer and clean up all resources
|
|
* CRITICAL: Call this when done to prevent memory leaks
|
|
*/
|
|
destroy(): void
|
|
/** Check if buffer has been destroyed */
|
|
isDestroyed(): boolean
|
|
/** Get buffer statistics for monitoring */
|
|
getStatistics(): BufferStatistics
|
|
/** Get current configuration */
|
|
getConfig(): BufferConfig
|
|
}
|
|
|
|
// ============================================================================
|
|
// LRU CACHE ENTRY WITH TIMESTAMP
|
|
// ============================================================================
|
|
|
|
interface LRUCacheEntry {
|
|
key: string
|
|
timestamp: number
|
|
}
|
|
|
|
/**
|
|
* Simple LRU tracking for history cache
|
|
* Tracks access times to enable partial cleanup
|
|
*/
|
|
class LRUTracker {
|
|
private entries: Map<string, number> = new Map()
|
|
|
|
add(key: string): void {
|
|
this.entries.set(key, Date.now())
|
|
}
|
|
|
|
has(key: string): boolean {
|
|
return this.entries.has(key)
|
|
}
|
|
|
|
touch(key: string): void {
|
|
if (this.entries.has(key)) {
|
|
this.entries.set(key, Date.now())
|
|
}
|
|
}
|
|
|
|
get size(): number {
|
|
return this.entries.size
|
|
}
|
|
|
|
/**
|
|
* Remove oldest entries up to the specified ratio
|
|
* @param ratio - Percentage of entries to remove (0-1)
|
|
* @returns Array of removed keys
|
|
*/
|
|
cleanup(ratio: number): string[] {
|
|
const removeCount = Math.floor(this.entries.size * ratio)
|
|
if (removeCount === 0) return []
|
|
|
|
// Sort by timestamp (oldest first)
|
|
const sorted: LRUCacheEntry[] = []
|
|
this.entries.forEach((timestamp, key) => {
|
|
sorted.push({ key, timestamp })
|
|
})
|
|
sorted.sort((a, b) => a.timestamp - b.timestamp)
|
|
|
|
// Remove oldest entries
|
|
const removed: string[] = []
|
|
for (let i = 0; i < removeCount && i < sorted.length; i++) {
|
|
const entry = sorted[i]
|
|
if (entry) {
|
|
this.entries.delete(entry.key)
|
|
removed.push(entry.key)
|
|
}
|
|
}
|
|
|
|
return removed
|
|
}
|
|
|
|
clear(): void {
|
|
this.entries.clear()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ADAPTIVE TIMEOUT ALGORITHM
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Adaptive timeout calculator based on buffer activity
|
|
* Adjusts timeout based on event rate to optimize batching
|
|
*/
|
|
class AdaptiveTimeoutCalculator {
|
|
private eventTimestamps: number[] = []
|
|
private readonly windowSize = 100 // Track last 100 events
|
|
private readonly minTimeout: number
|
|
private readonly maxTimeout: number
|
|
private currentTimeout: number
|
|
|
|
constructor(minTimeout: number, maxTimeout: number, initialTimeout: number) {
|
|
this.minTimeout = minTimeout
|
|
this.maxTimeout = maxTimeout
|
|
this.currentTimeout = initialTimeout
|
|
}
|
|
|
|
/**
|
|
* Record an event and recalculate optimal timeout
|
|
*/
|
|
recordEvent(): void {
|
|
const now = Date.now()
|
|
this.eventTimestamps.push(now)
|
|
|
|
// Keep only recent events
|
|
if (this.eventTimestamps.length > this.windowSize) {
|
|
this.eventTimestamps.shift()
|
|
}
|
|
|
|
this.recalculateTimeout()
|
|
}
|
|
|
|
/**
|
|
* Get current calculated timeout
|
|
*/
|
|
getTimeout(): number {
|
|
return this.currentTimeout
|
|
}
|
|
|
|
/**
|
|
* Recalculate timeout based on event rate
|
|
* High rate = shorter timeout (flush more often)
|
|
* Low rate = longer timeout (batch more events)
|
|
*/
|
|
private recalculateTimeout(): void {
|
|
if (this.eventTimestamps.length < 2) {
|
|
return // Not enough data
|
|
}
|
|
|
|
const oldest = this.eventTimestamps[0]
|
|
const newest = this.eventTimestamps[this.eventTimestamps.length - 1]
|
|
if (oldest === undefined || newest === undefined) return
|
|
const timeSpan = newest - oldest
|
|
|
|
if (timeSpan === 0) return
|
|
|
|
// Calculate events per second
|
|
const eventsPerSecond = (this.eventTimestamps.length / timeSpan) * 1000
|
|
|
|
// Adjust timeout inversely to event rate
|
|
// High rate (>100 eps) = min timeout
|
|
// Low rate (<1 eps) = max timeout
|
|
if (eventsPerSecond > 100) {
|
|
this.currentTimeout = this.minTimeout
|
|
} else if (eventsPerSecond < 1) {
|
|
this.currentTimeout = this.maxTimeout
|
|
} else {
|
|
// Linear interpolation between min and max
|
|
const ratio = Math.log10(eventsPerSecond + 1) / 2 // 0 to 1 scale
|
|
this.currentTimeout = this.maxTimeout - (this.maxTimeout - this.minTimeout) * ratio
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.eventTimestamps = []
|
|
this.currentTimeout = (this.minTimeout + this.maxTimeout) / 2
|
|
}
|
|
|
|
/**
|
|
* Get current adaptive mode based on timeout
|
|
* Returns 'aggressive', 'balanced', or 'conservative'
|
|
*/
|
|
getMode(): 'aggressive' | 'balanced' | 'conservative' {
|
|
if (this.currentTimeout <= this.minTimeout * 1.5) {
|
|
return 'aggressive'
|
|
} else if (this.currentTimeout >= this.maxTimeout * 0.8) {
|
|
return 'conservative'
|
|
}
|
|
|
|
return 'balanced'
|
|
}
|
|
|
|
/**
|
|
* Get current event rate in events per second
|
|
*/
|
|
getEventRate(): number {
|
|
if (this.eventTimestamps.length < 2) {
|
|
return 0
|
|
}
|
|
|
|
const oldest = this.eventTimestamps[0]
|
|
const newest = this.eventTimestamps[this.eventTimestamps.length - 1]
|
|
if (oldest === undefined || newest === undefined) return 0
|
|
const timeSpan = newest - oldest
|
|
if (timeSpan === 0) return 0
|
|
return (this.eventTimestamps.length / timeSpan) * 1000
|
|
}
|
|
|
|
/**
|
|
* Check if system is healthy based on current mode
|
|
* Conservative mode with low event rate is healthy
|
|
* Aggressive mode might indicate high load
|
|
*/
|
|
isHealthy(): boolean {
|
|
const mode = this.getMode()
|
|
// System is considered healthy if not in aggressive mode (high load)
|
|
return mode !== 'aggressive'
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN EVENT BUFFER FACTORY
|
|
// ============================================================================
|
|
|
|
/**
|
|
* The event buffer logically consolidates different events into a single event
|
|
* making the data processing more efficient.
|
|
*
|
|
* Enterprise-grade features:
|
|
* - Environment variable configuration
|
|
* - Proper resource cleanup (destroy method)
|
|
* - Buffer overflow detection and prevention
|
|
* - Adaptive timeout based on event rate
|
|
* - LRU cache cleanup instead of full clear
|
|
* - Prometheus metrics integration
|
|
* - Force flush capability
|
|
* - Comprehensive statistics
|
|
*/
|
|
export const makeEventBuffer = (
|
|
logger: ILogger,
|
|
configOverride?: Partial<BufferConfig>
|
|
): BaileysBufferableEventEmitter => {
|
|
// Load configuration
|
|
const config: BufferConfig = {
|
|
...loadBufferConfig(),
|
|
...configOverride
|
|
}
|
|
|
|
const ev = new EventEmitter()
|
|
const historyCache = new LRUTracker()
|
|
|
|
let data = makeBufferData()
|
|
let isBuffering = false
|
|
let destroyed = false
|
|
let bufferTimeout: NodeJS.Timeout | null = null
|
|
let flushPendingTimeout: NodeJS.Timeout | null = null
|
|
let bufferCount = 0
|
|
let currentEventCount = 0
|
|
|
|
// Statistics tracking
|
|
const stats: BufferStatistics = {
|
|
totalFlushes: 0,
|
|
forcedFlushes: 0,
|
|
totalEventsBuffered: 0,
|
|
currentBufferSize: 0,
|
|
peakBufferSize: 0,
|
|
overflowsDetected: 0,
|
|
currentTimeout: config.bufferTimeoutMs,
|
|
historyCacheSize: 0,
|
|
lruCleanups: 0,
|
|
avgEventsPerFlush: 0,
|
|
createdAt: Date.now(),
|
|
lastFlushAt: null
|
|
}
|
|
|
|
// Adaptive timeout calculator
|
|
const adaptiveTimeout = new AdaptiveTimeoutCalculator(
|
|
config.minBufferTimeoutMs,
|
|
config.maxBufferTimeoutMs,
|
|
config.bufferTimeoutMs
|
|
)
|
|
|
|
// Metrics integration (lazy loaded to avoid circular deps)
|
|
let metricsModule: typeof import('./prometheus-metrics') | null = null
|
|
let metricsQueue: Array<() => void> = []
|
|
let metricsImportFailed = false
|
|
const MAX_METRICS_QUEUE_SIZE = 1000 // Cap to prevent unbounded growth
|
|
|
|
if (config.enableMetrics) {
|
|
import('./prometheus-metrics')
|
|
.then(m => {
|
|
metricsModule = m
|
|
logger.debug({ queuedCount: metricsQueue.length }, '📊 Prometheus metrics loaded, flushing buffered metrics')
|
|
// Flush buffered metrics
|
|
metricsQueue.forEach(fn => fn())
|
|
metricsQueue = []
|
|
})
|
|
.catch(() => {
|
|
logger.debug('Prometheus metrics not available for event buffer')
|
|
metricsImportFailed = true
|
|
// Clear queue to prevent memory leak
|
|
metricsQueue = []
|
|
})
|
|
}
|
|
|
|
// Helper to record metrics with buffer support
|
|
const recordMetrics = (eventType: string, count: number) => {
|
|
if (metricsModule) {
|
|
metricsModule.recordEventBuffered(eventType, count)
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
// Buffer metric call until module loads (with size limit)
|
|
metricsQueue.push(() => metricsModule?.recordEventBuffered(eventType, count))
|
|
}
|
|
// If import failed or queue is full, silently drop metric
|
|
}
|
|
|
|
const recordFlushMetrics = (eventCount: number, forced: boolean, cacheSize: number) => {
|
|
if (metricsModule) {
|
|
metricsModule.recordBufferFlush(eventCount, forced, cacheSize)
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() => metricsModule?.recordBufferFlush(eventCount, forced, cacheSize))
|
|
}
|
|
}
|
|
|
|
// Cleanup helper
|
|
const clearAllTimers = () => {
|
|
if (bufferTimeout) {
|
|
clearTimeout(bufferTimeout)
|
|
bufferTimeout = null
|
|
}
|
|
|
|
if (flushPendingTimeout) {
|
|
clearTimeout(flushPendingTimeout)
|
|
flushPendingTimeout = null
|
|
}
|
|
}
|
|
|
|
// Take the generic event and fire it as a baileys event
|
|
ev.on('event', (map: BaileysEventData) => {
|
|
for (const event in map) {
|
|
ev.emit(event, map[event as keyof BaileysEventMap])
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Check for buffer overflow and handle it
|
|
*/
|
|
function checkBufferOverflow(): boolean {
|
|
if (currentEventCount >= config.maxBufferSize) {
|
|
stats.overflowsDetected++
|
|
logger.warn(
|
|
{
|
|
currentSize: currentEventCount,
|
|
maxSize: config.maxBufferSize
|
|
},
|
|
'Buffer overflow detected, forcing flush'
|
|
)
|
|
logEventBuffer('buffer_overflow', {
|
|
currentSize: currentEventCount,
|
|
maxSize: config.maxBufferSize
|
|
})
|
|
// Record overflow metric
|
|
if (metricsModule) {
|
|
metricsModule.recordBufferOverflow()
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() => metricsModule?.recordBufferOverflow())
|
|
}
|
|
|
|
flush(true)
|
|
return true
|
|
}
|
|
|
|
// Warn if approaching threshold
|
|
const threshold = config.maxBufferSize * config.bufferWarnThreshold
|
|
if (currentEventCount >= threshold && currentEventCount < config.maxBufferSize) {
|
|
logger.debug(
|
|
{
|
|
currentSize: currentEventCount,
|
|
threshold,
|
|
maxSize: config.maxBufferSize
|
|
},
|
|
'Buffer approaching overflow threshold'
|
|
)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Perform LRU cleanup on history cache
|
|
*/
|
|
function cleanupHistoryCache(): void {
|
|
if (historyCache.size > config.maxHistoryCacheSize) {
|
|
const removed = historyCache.cleanup(config.lruCleanupRatio)
|
|
stats.lruCleanups++
|
|
logger.debug(
|
|
{
|
|
removed: removed.length,
|
|
remaining: historyCache.size,
|
|
maxSize: config.maxHistoryCacheSize
|
|
},
|
|
'LRU cleanup performed on history cache'
|
|
)
|
|
logEventBuffer('cache_cleanup', {
|
|
removed: removed.length,
|
|
remaining: historyCache.size
|
|
})
|
|
// Record metrics for cache cleanup
|
|
if (metricsModule) {
|
|
metricsModule.recordCacheCleanup(removed.length)
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() => metricsModule?.recordCacheCleanup(removed.length))
|
|
}
|
|
}
|
|
}
|
|
|
|
function buffer() {
|
|
if (destroyed) {
|
|
logger.warn('Attempted to buffer on destroyed event buffer')
|
|
return
|
|
}
|
|
|
|
if (!isBuffering) {
|
|
logger.debug('Event buffer activated')
|
|
logEventBuffer('buffer_start')
|
|
isBuffering = true
|
|
bufferCount = 0
|
|
currentEventCount = 0
|
|
|
|
clearAllTimers()
|
|
|
|
// Use adaptive timeout if enabled
|
|
const timeout = config.enableAdaptiveTimeout ? adaptiveTimeout.getTimeout() : config.bufferTimeoutMs
|
|
stats.currentTimeout = timeout
|
|
|
|
bufferTimeout = setTimeout(() => {
|
|
if (isBuffering) {
|
|
logger.warn({ timeout }, 'Buffer timeout reached, auto-flushing')
|
|
logEventBuffer('buffer_timeout', { timeout })
|
|
stats.forcedFlushes++
|
|
flush(true)
|
|
}
|
|
}, timeout)
|
|
}
|
|
|
|
// Always increment count when requested
|
|
bufferCount++
|
|
}
|
|
|
|
function flush(force = false): boolean {
|
|
if (destroyed) {
|
|
logger.warn('Attempted to flush destroyed event buffer')
|
|
return false
|
|
}
|
|
|
|
if (!isBuffering && !force) {
|
|
return false
|
|
}
|
|
|
|
const eventCount = currentEventCount
|
|
const flushStartTime = Date.now()
|
|
logger.debug({ bufferCount, eventCount, force }, 'Flushing event buffer')
|
|
|
|
isBuffering = false
|
|
bufferCount = 0
|
|
currentEventCount = 0
|
|
stats.totalFlushes++
|
|
stats.lastFlushAt = Date.now()
|
|
|
|
// Update average events per flush
|
|
stats.avgEventsPerFlush =
|
|
stats.totalFlushes > 0
|
|
? (stats.avgEventsPerFlush * (stats.totalFlushes - 1) + eventCount) / stats.totalFlushes
|
|
: eventCount
|
|
|
|
// Clear timeouts
|
|
clearAllTimers()
|
|
|
|
// Perform LRU cleanup instead of full clear
|
|
cleanupHistoryCache()
|
|
stats.historyCacheSize = historyCache.size
|
|
|
|
// Record metrics
|
|
recordFlushMetrics(eventCount, force, historyCache.size)
|
|
|
|
// Update adaptive metrics
|
|
if (config.enableAdaptiveTimeout) {
|
|
if (metricsModule) {
|
|
metricsModule.updateAdaptiveMetrics(adaptiveTimeout.getEventRate(), adaptiveTimeout.isHealthy())
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() =>
|
|
metricsModule?.updateAdaptiveMetrics(adaptiveTimeout.getEventRate(), adaptiveTimeout.isHealthy())
|
|
)
|
|
}
|
|
}
|
|
|
|
// Log with [BAILEYS] prefix - use getMode() to avoid duplicating mode calculation logic
|
|
const flushDuration = Date.now() - flushStartTime
|
|
logEventBuffer('buffer_flush', {
|
|
flushCount: stats.totalFlushes,
|
|
historyCacheSize: stats.historyCacheSize,
|
|
mode: config.enableAdaptiveTimeout ? adaptiveTimeout.getMode() : 'fixed',
|
|
...(flushDuration > 5 ? { duration: `${flushDuration}ms` } : {})
|
|
})
|
|
|
|
const newData = makeBufferData()
|
|
const chatUpdates = Object.values(data.chatUpdates)
|
|
let conditionalChatUpdatesLeft = 0
|
|
for (const update of chatUpdates) {
|
|
if (update.conditional) {
|
|
conditionalChatUpdatesLeft += 1
|
|
const updateId = update.id
|
|
if (updateId) {
|
|
newData.chatUpdates[updateId] = update
|
|
delete data.chatUpdates[updateId]
|
|
} else {
|
|
logger.debug({ update }, 'conditional chat update missing id, not carrying forward')
|
|
}
|
|
}
|
|
}
|
|
|
|
const consolidatedData = consolidateEvents(data)
|
|
if (Object.keys(consolidatedData).length) {
|
|
ev.emit('event', consolidatedData)
|
|
}
|
|
|
|
data = newData
|
|
|
|
logger.trace({ conditionalChatUpdatesLeft, eventCount }, 'released buffered events')
|
|
|
|
// Reset adaptive timeout calculator on flush
|
|
if (config.enableAdaptiveTimeout) {
|
|
adaptiveTimeout.reset()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function destroy(): void {
|
|
if (destroyed) {
|
|
logger.debug('Event buffer already destroyed')
|
|
return
|
|
}
|
|
|
|
logger.debug('Destroying event buffer')
|
|
const hadPendingFlush = isBuffering
|
|
destroyed = true
|
|
|
|
// Flush any remaining events
|
|
if (hadPendingFlush) {
|
|
flush(true)
|
|
// Record final flush metric
|
|
if (metricsModule) {
|
|
metricsModule.recordBufferFinalFlush()
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() => metricsModule?.recordBufferFinalFlush())
|
|
}
|
|
}
|
|
|
|
// Clear all timers
|
|
clearAllTimers()
|
|
|
|
// Clear all data structures
|
|
data = makeBufferData()
|
|
historyCache.clear()
|
|
|
|
// Remove all event listeners
|
|
ev.removeAllListeners()
|
|
|
|
// Record buffer destroyed metric
|
|
if (metricsModule) {
|
|
metricsModule.recordBufferDestroyed('normal', hadPendingFlush)
|
|
} else if (!metricsImportFailed && metricsQueue.length < MAX_METRICS_QUEUE_SIZE) {
|
|
metricsQueue.push(() => metricsModule?.recordBufferDestroyed('normal', hadPendingFlush))
|
|
}
|
|
|
|
logger.debug('Event buffer destroyed successfully')
|
|
}
|
|
|
|
return {
|
|
process(handler) {
|
|
if (destroyed) {
|
|
throw new Error('Cannot process on destroyed event buffer')
|
|
}
|
|
|
|
const listener = async (map: BaileysEventData) => {
|
|
await handler(map)
|
|
}
|
|
|
|
ev.on('event', listener)
|
|
return () => {
|
|
ev.off('event', listener)
|
|
}
|
|
},
|
|
emit<T extends BaileysEvent>(event: BaileysEvent, evData: BaileysEventMap[T]) {
|
|
if (destroyed) {
|
|
logger.warn({ event }, 'Attempted to emit on destroyed event buffer')
|
|
return false
|
|
}
|
|
|
|
// Check if this is a messages.upsert with a different type than what's buffered
|
|
// If so, flush the buffered messages first to avoid type overshadowing
|
|
if (event === 'messages.upsert') {
|
|
const { type } = evData as BaileysEventMap['messages.upsert']
|
|
const existingUpserts = Object.values(data.messageUpserts)
|
|
if (existingUpserts.length > 0) {
|
|
const bufferedType = existingUpserts[0]?.type
|
|
if (bufferedType !== type) {
|
|
logger.debug({ bufferedType, newType: type }, 'messages.upsert type mismatch, emitting buffered messages')
|
|
// Emit the buffered messages with their correct type
|
|
ev.emit('event', {
|
|
'messages.upsert': {
|
|
messages: existingUpserts.map(m => m.message),
|
|
type: bufferedType
|
|
}
|
|
})
|
|
// Clear the message upserts from the buffer
|
|
data.messageUpserts = {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isBuffering && BUFFERABLE_EVENT_SET.has(event)) {
|
|
// Record event for adaptive timeout
|
|
if (config.enableAdaptiveTimeout) {
|
|
adaptiveTimeout.recordEvent()
|
|
}
|
|
|
|
// Track statistics
|
|
currentEventCount++
|
|
stats.totalEventsBuffered++
|
|
stats.currentBufferSize = currentEventCount
|
|
if (currentEventCount > stats.peakBufferSize) {
|
|
stats.peakBufferSize = currentEventCount
|
|
}
|
|
|
|
// Record metrics
|
|
recordMetrics(event, 1)
|
|
|
|
// Check for overflow
|
|
if (checkBufferOverflow()) {
|
|
// Buffer was flushed due to overflow, re-buffer this event
|
|
buffer()
|
|
}
|
|
|
|
append(data, historyCache, event as BufferableEvent, evData, logger)
|
|
return true
|
|
}
|
|
|
|
return ev.emit('event', { [event]: evData })
|
|
},
|
|
isBuffering() {
|
|
return isBuffering && !destroyed
|
|
},
|
|
isDestroyed() {
|
|
return destroyed
|
|
},
|
|
buffer,
|
|
flush,
|
|
destroy,
|
|
getStatistics() {
|
|
return {
|
|
...stats,
|
|
currentBufferSize: currentEventCount,
|
|
historyCacheSize: historyCache.size,
|
|
currentTimeout: config.enableAdaptiveTimeout ? adaptiveTimeout.getTimeout() : config.bufferTimeoutMs
|
|
}
|
|
},
|
|
getConfig() {
|
|
return { ...config }
|
|
},
|
|
createBufferedFunction(work) {
|
|
// Track the timeout for this specific function call
|
|
let functionTimeout: NodeJS.Timeout | null = null
|
|
|
|
return async (...args) => {
|
|
if (destroyed) {
|
|
throw new Error('Cannot execute buffered function on destroyed event buffer')
|
|
}
|
|
|
|
buffer()
|
|
try {
|
|
const result = await work(...args)
|
|
|
|
// If this is the only buffer, flush after a small delay
|
|
if (bufferCount === 1) {
|
|
// Clear any existing function timeout to prevent orphaned timers
|
|
if (functionTimeout) {
|
|
clearTimeout(functionTimeout)
|
|
}
|
|
|
|
functionTimeout = setTimeout(() => {
|
|
functionTimeout = null
|
|
if (isBuffering && bufferCount === 1 && !destroyed) {
|
|
flush()
|
|
}
|
|
}, config.flushDebounceMs)
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
// Clear timeout on error
|
|
if (functionTimeout) {
|
|
clearTimeout(functionTimeout)
|
|
functionTimeout = null
|
|
}
|
|
|
|
throw error
|
|
} finally {
|
|
bufferCount = Math.max(0, bufferCount - 1)
|
|
if (bufferCount === 0 && !destroyed) {
|
|
// Only schedule ONE timeout, not multiple
|
|
if (!flushPendingTimeout) {
|
|
flushPendingTimeout = setTimeout(() => {
|
|
flushPendingTimeout = null
|
|
if (!destroyed) {
|
|
flush()
|
|
}
|
|
}, config.flushDebounceMs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
on: (...args) => {
|
|
if (destroyed) {
|
|
throw new Error('Cannot add listener to destroyed event buffer')
|
|
}
|
|
|
|
return ev.on(...args)
|
|
},
|
|
off: (...args) => ev.off(...args),
|
|
removeAllListeners: (...args) => ev.removeAllListeners(...args)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// BUFFER DATA STRUCTURE
|
|
// ============================================================================
|
|
|
|
const makeBufferData = (): BufferedEventData => {
|
|
return {
|
|
historySets: {
|
|
chats: {},
|
|
messages: {},
|
|
contacts: {},
|
|
pastParticipants: {},
|
|
isLatest: false,
|
|
empty: true
|
|
},
|
|
chatUpserts: {},
|
|
chatUpdates: {},
|
|
chatDeletes: new Set(),
|
|
contactUpserts: {},
|
|
contactUpdates: {},
|
|
messageUpserts: {},
|
|
messageUpdates: {},
|
|
messageReactions: {},
|
|
messageDeletes: {},
|
|
messageReceipts: {},
|
|
groupUpdates: {},
|
|
lidMappings: {}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EVENT APPEND LOGIC
|
|
// ============================================================================
|
|
|
|
function append<E extends BufferableEvent>(
|
|
data: BufferedEventData,
|
|
historyCache: LRUTracker,
|
|
event: E,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
eventData: any,
|
|
logger: ILogger
|
|
) {
|
|
switch (event) {
|
|
case 'messaging-history.set':
|
|
for (const chat of eventData.chats as Chat[]) {
|
|
const id = chat.id || ''
|
|
const existingChat = data.historySets.chats[id]
|
|
if (existingChat) {
|
|
existingChat.endOfHistoryTransferType = chat.endOfHistoryTransferType
|
|
}
|
|
|
|
if (!existingChat && !historyCache.has(id)) {
|
|
data.historySets.chats[id] = chat
|
|
historyCache.add(id)
|
|
|
|
absorbingChatUpdate(chat)
|
|
} else {
|
|
// Touch existing entry for LRU tracking
|
|
historyCache.touch(id)
|
|
}
|
|
}
|
|
|
|
for (const contact of eventData.contacts as Contact[]) {
|
|
const existingContact = data.historySets.contacts[contact.id]
|
|
if (existingContact) {
|
|
Object.assign(existingContact, trimUndefined(contact))
|
|
} else {
|
|
const historyContactId = `c:${contact.id}`
|
|
const hasAnyName = contact.notify || contact.name || contact.verifiedName
|
|
if (!historyCache.has(historyContactId) || hasAnyName) {
|
|
data.historySets.contacts[contact.id] = contact
|
|
historyCache.add(historyContactId)
|
|
} else {
|
|
historyCache.touch(historyContactId)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const message of eventData.messages as WAMessage[]) {
|
|
const key = stringifyMessageKey(message.key)
|
|
const existingMsg = data.historySets.messages[key]
|
|
if (!existingMsg && !historyCache.has(key)) {
|
|
data.historySets.messages[key] = message
|
|
historyCache.add(key)
|
|
} else {
|
|
historyCache.touch(key)
|
|
}
|
|
}
|
|
|
|
// Merge pastParticipants with deduplication by groupJid and by userJid within each group.
|
|
// Multiple HistorySync chunks can carry the same group -- we merge rather than concatenate
|
|
// to avoid duplicate entries in the final event delivered to the consumer.
|
|
for (const group of (eventData.pastParticipants ?? []) as proto.IPastParticipants[]) {
|
|
const groupJid = group.groupJid
|
|
if (!groupJid) continue
|
|
|
|
if (!data.historySets.pastParticipants[groupJid]) {
|
|
data.historySets.pastParticipants[groupJid] = []
|
|
}
|
|
|
|
const existing = data.historySets.pastParticipants[groupJid]
|
|
const seenJids = new Set(existing.map(p => p.userJid).filter(Boolean))
|
|
|
|
for (const participant of (group.pastParticipants ?? []) as proto.IPastParticipant[]) {
|
|
if (participant.userJid && !seenJids.has(participant.userJid)) {
|
|
existing.push(participant)
|
|
seenJids.add(participant.userJid)
|
|
}
|
|
}
|
|
}
|
|
|
|
data.historySets.empty = false
|
|
data.historySets.syncType = eventData.syncType
|
|
data.historySets.progress = eventData.progress
|
|
data.historySets.chunkOrder = eventData.chunkOrder
|
|
data.historySets.peerDataRequestSessionId = eventData.peerDataRequestSessionId
|
|
data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest
|
|
|
|
break
|
|
case 'chats.upsert':
|
|
for (const chat of eventData as Chat[]) {
|
|
const id = chat.id || ''
|
|
let upsert = data.chatUpserts[id]
|
|
if (id && !upsert) {
|
|
upsert = data.historySets.chats[id]
|
|
if (upsert) {
|
|
logger.debug({ chatId: id }, 'absorbed chat upsert in chat set')
|
|
}
|
|
}
|
|
|
|
if (upsert) {
|
|
upsert = concatChats(upsert, chat)
|
|
} else {
|
|
upsert = chat
|
|
data.chatUpserts[id] = upsert
|
|
}
|
|
|
|
absorbingChatUpdate(upsert)
|
|
|
|
if (data.chatDeletes.has(id)) {
|
|
data.chatDeletes.delete(id)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'chats.update':
|
|
for (const update of eventData as ChatUpdate[]) {
|
|
const chatId = update.id
|
|
if (!chatId) {
|
|
logger.debug({ update }, 'chats.update: update missing id, skipping')
|
|
continue
|
|
}
|
|
|
|
const conditionMatches = update.conditional ? update.conditional(data) : true
|
|
if (conditionMatches) {
|
|
delete update.conditional
|
|
|
|
// if there is an existing upsert, merge the update into it
|
|
const upsert = data.historySets.chats[chatId] || data.chatUpserts[chatId]
|
|
if (upsert) {
|
|
concatChats(upsert, update)
|
|
} else {
|
|
// merge the update into the existing update
|
|
const chatUpdate = data.chatUpdates[chatId] || {}
|
|
data.chatUpdates[chatId] = concatChats(chatUpdate, update)
|
|
}
|
|
} else if (conditionMatches === undefined) {
|
|
// condition yet to be fulfilled
|
|
data.chatUpdates[chatId] = update
|
|
}
|
|
// otherwise -- condition not met, update is invalid
|
|
|
|
// if the chat has been updated
|
|
// ignore any existing chat delete
|
|
if (data.chatDeletes.has(chatId)) {
|
|
data.chatDeletes.delete(chatId)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'chats.delete':
|
|
for (const chatId of eventData as string[]) {
|
|
if (!data.chatDeletes.has(chatId)) {
|
|
data.chatDeletes.add(chatId)
|
|
}
|
|
|
|
// remove any prior updates & upserts
|
|
if (data.chatUpdates[chatId]) {
|
|
delete data.chatUpdates[chatId]
|
|
}
|
|
|
|
if (data.chatUpserts[chatId]) {
|
|
delete data.chatUpserts[chatId]
|
|
}
|
|
|
|
if (data.historySets.chats[chatId]) {
|
|
delete data.historySets.chats[chatId]
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'contacts.upsert':
|
|
for (const contact of eventData as Contact[]) {
|
|
let upsert = data.contactUpserts[contact.id]
|
|
if (!upsert) {
|
|
upsert = data.historySets.contacts[contact.id]
|
|
if (upsert) {
|
|
logger.debug({ contactId: contact.id }, 'absorbed contact upsert in contact set')
|
|
}
|
|
}
|
|
|
|
if (upsert) {
|
|
upsert = Object.assign(upsert, trimUndefined(contact))
|
|
} else {
|
|
upsert = contact
|
|
data.contactUpserts[contact.id] = upsert
|
|
}
|
|
|
|
const existingContactUpdate = data.contactUpdates[contact.id]
|
|
if (existingContactUpdate) {
|
|
upsert = Object.assign(existingContactUpdate, trimUndefined(contact)) as Contact
|
|
delete data.contactUpdates[contact.id]
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'contacts.update':
|
|
const contactUpdates = eventData as BaileysEventMap['contacts.update']
|
|
for (const update of contactUpdates) {
|
|
const id = update.id
|
|
if (!id) {
|
|
logger.debug({ update }, 'contacts.update: update missing id, skipping')
|
|
continue
|
|
}
|
|
|
|
// merge into prior upsert
|
|
const upsert = data.historySets.contacts[id] || data.contactUpserts[id]
|
|
if (upsert) {
|
|
Object.assign(upsert, update)
|
|
} else {
|
|
// merge into prior update
|
|
const contactUpdate = data.contactUpdates[id] || {}
|
|
data.contactUpdates[id] = Object.assign(contactUpdate, update)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'messages.upsert':
|
|
const { messages, type } = eventData as BaileysEventMap['messages.upsert']
|
|
for (const message of messages) {
|
|
const key = stringifyMessageKey(message.key)
|
|
let existing = data.messageUpserts[key]?.message
|
|
if (!existing) {
|
|
existing = data.historySets.messages[key]
|
|
if (existing) {
|
|
logger.debug({ messageId: key }, 'absorbed message upsert in message set')
|
|
}
|
|
}
|
|
|
|
if (existing) {
|
|
message.messageTimestamp = existing.messageTimestamp
|
|
}
|
|
|
|
if (data.messageUpdates[key]) {
|
|
logger.debug('absorbed prior message update in message upsert')
|
|
Object.assign(message, data.messageUpdates[key].update)
|
|
delete data.messageUpdates[key]
|
|
}
|
|
|
|
if (data.historySets.messages[key]) {
|
|
data.historySets.messages[key] = message
|
|
} else {
|
|
data.messageUpserts[key] = {
|
|
message,
|
|
type: type === 'notify' || data.messageUpserts[key]?.type === 'notify' ? 'notify' : type
|
|
}
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'messages.update':
|
|
const msgUpdates = eventData as BaileysEventMap['messages.update']
|
|
for (const { key, update } of msgUpdates) {
|
|
const keyStr = stringifyMessageKey(key)
|
|
const existing = data.historySets.messages[keyStr] || data.messageUpserts[keyStr]?.message
|
|
if (existing) {
|
|
Object.assign(existing, update)
|
|
// if the message was received & read by us
|
|
// the chat counter must have been incremented
|
|
// so we need to decrement it
|
|
if (update.status === WAMessageStatus.READ && !key.fromMe) {
|
|
decrementChatReadCounterIfMsgDidUnread(existing)
|
|
}
|
|
} else {
|
|
const msgUpdate = data.messageUpdates[keyStr] || { key, update: {} }
|
|
Object.assign(msgUpdate.update, update)
|
|
data.messageUpdates[keyStr] = msgUpdate
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'messages.delete':
|
|
const deleteData = eventData as BaileysEventMap['messages.delete']
|
|
if ('keys' in deleteData) {
|
|
const { keys } = deleteData
|
|
for (const key of keys) {
|
|
const keyStr = stringifyMessageKey(key)
|
|
if (!data.messageDeletes[keyStr]) {
|
|
data.messageDeletes[keyStr] = key
|
|
}
|
|
|
|
if (data.messageUpserts[keyStr]) {
|
|
delete data.messageUpserts[keyStr]
|
|
}
|
|
|
|
if (data.messageUpdates[keyStr]) {
|
|
delete data.messageUpdates[keyStr]
|
|
}
|
|
}
|
|
} else {
|
|
// TODO: add support
|
|
}
|
|
|
|
break
|
|
case 'messages.reaction':
|
|
const reactions = eventData as BaileysEventMap['messages.reaction']
|
|
for (const { key, reaction } of reactions) {
|
|
const keyStr = stringifyMessageKey(key)
|
|
const existing = data.messageUpserts[keyStr]
|
|
if (existing) {
|
|
updateMessageWithReaction(existing.message, reaction)
|
|
} else {
|
|
data.messageReactions[keyStr] = data.messageReactions[keyStr] || { key, reactions: [] }
|
|
updateMessageWithReaction(data.messageReactions[keyStr], reaction)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'message-receipt.update':
|
|
const receipts = eventData as BaileysEventMap['message-receipt.update']
|
|
for (const { key, receipt } of receipts) {
|
|
const keyStr = stringifyMessageKey(key)
|
|
const existing = data.messageUpserts[keyStr]
|
|
if (existing) {
|
|
updateMessageWithReceipt(existing.message, receipt)
|
|
} else {
|
|
data.messageReceipts[keyStr] = data.messageReceipts[keyStr] || { key, userReceipt: [] }
|
|
updateMessageWithReceipt(data.messageReceipts[keyStr], receipt)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'groups.update':
|
|
const groupUpdates = eventData as BaileysEventMap['groups.update']
|
|
for (const update of groupUpdates) {
|
|
const id = update.id
|
|
if (!id) {
|
|
logger.debug({ update }, 'groups.update: update missing id, skipping')
|
|
continue
|
|
}
|
|
|
|
const groupUpdate = data.groupUpdates[id] || {}
|
|
if (!data.groupUpdates[id]) {
|
|
data.groupUpdates[id] = Object.assign(groupUpdate, update)
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'lid-mapping.update':
|
|
const lidMappings = eventData as BaileysEventMap['lid-mapping.update']
|
|
for (const mapping of lidMappings) {
|
|
const key = `${mapping.lid}-${mapping.pn}`
|
|
if (!data.lidMappings[key]) {
|
|
data.lidMappings[key] = mapping
|
|
}
|
|
}
|
|
|
|
break
|
|
default:
|
|
throw new Error(`"${event}" cannot be buffered`)
|
|
}
|
|
|
|
function absorbingChatUpdate(existing: Chat) {
|
|
const chatId = existing.id || ''
|
|
const update = data.chatUpdates[chatId]
|
|
if (update) {
|
|
const conditionMatches = update.conditional ? update.conditional(data) : true
|
|
if (conditionMatches) {
|
|
delete update.conditional
|
|
logger.debug({ chatId }, 'absorbed chat update in existing chat')
|
|
Object.assign(existing, concatChats(update as Chat, existing))
|
|
delete data.chatUpdates[chatId]
|
|
} else if (conditionMatches === false) {
|
|
logger.debug({ chatId }, 'chat update condition fail, removing')
|
|
delete data.chatUpdates[chatId]
|
|
}
|
|
}
|
|
}
|
|
|
|
function decrementChatReadCounterIfMsgDidUnread(message: WAMessage) {
|
|
// decrement chat unread counter
|
|
// if the message has already been marked read by us
|
|
const chatId = message.key.remoteJid
|
|
if (!chatId) {
|
|
logger.debug({ messageKey: message.key }, 'decrementChatReadCounter: remoteJid missing, skipping')
|
|
return
|
|
}
|
|
|
|
const chat = data.chatUpdates[chatId] || data.chatUpserts[chatId]
|
|
if (
|
|
isRealMessage(message) &&
|
|
shouldIncrementChatUnread(message) &&
|
|
typeof chat?.unreadCount === 'number' &&
|
|
chat.unreadCount > 0
|
|
) {
|
|
logger.debug({ chatId: chat.id }, 'decrementing chat counter')
|
|
chat.unreadCount -= 1
|
|
if (chat.unreadCount === 0) {
|
|
delete chat.unreadCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EVENT CONSOLIDATION
|
|
// ============================================================================
|
|
|
|
function consolidateEvents(data: BufferedEventData) {
|
|
const map: BaileysEventData = {}
|
|
|
|
if (!data.historySets.empty) {
|
|
map['messaging-history.set'] = {
|
|
chats: Object.values(data.historySets.chats),
|
|
messages: Object.values(data.historySets.messages),
|
|
contacts: Object.values(data.historySets.contacts),
|
|
// Convert dedup map back to array. Each entry has groupJid + participants (all unique userJids).
|
|
pastParticipants: Object.entries(data.historySets.pastParticipants).map(
|
|
([groupJid, participants]) => ({ groupJid, pastParticipants: participants })
|
|
),
|
|
syncType: data.historySets.syncType,
|
|
progress: data.historySets.progress,
|
|
isLatest: data.historySets.isLatest,
|
|
chunkOrder: data.historySets.chunkOrder,
|
|
peerDataRequestSessionId: data.historySets.peerDataRequestSessionId
|
|
}
|
|
}
|
|
|
|
const chatUpsertList = Object.values(data.chatUpserts)
|
|
if (chatUpsertList.length) {
|
|
map['chats.upsert'] = chatUpsertList
|
|
}
|
|
|
|
const chatUpdateList = Object.values(data.chatUpdates)
|
|
if (chatUpdateList.length) {
|
|
map['chats.update'] = chatUpdateList
|
|
}
|
|
|
|
const chatDeleteList = Array.from(data.chatDeletes)
|
|
if (chatDeleteList.length) {
|
|
map['chats.delete'] = chatDeleteList
|
|
}
|
|
|
|
const messageUpsertList = Object.values(data.messageUpserts)
|
|
if (messageUpsertList.length) {
|
|
const type = messageUpsertList[0]?.type ?? 'notify'
|
|
map['messages.upsert'] = {
|
|
messages: messageUpsertList.map(m => m.message),
|
|
type
|
|
}
|
|
}
|
|
|
|
const messageUpdateList = Object.values(data.messageUpdates)
|
|
if (messageUpdateList.length) {
|
|
map['messages.update'] = messageUpdateList
|
|
}
|
|
|
|
const messageDeleteList = Object.values(data.messageDeletes)
|
|
if (messageDeleteList.length) {
|
|
map['messages.delete'] = { keys: messageDeleteList }
|
|
}
|
|
|
|
const messageReactionList = Object.values(data.messageReactions).flatMap(({ key, reactions }) =>
|
|
reactions.flatMap(reaction => ({ key, reaction }))
|
|
)
|
|
if (messageReactionList.length) {
|
|
map['messages.reaction'] = messageReactionList
|
|
}
|
|
|
|
const messageReceiptList = Object.values(data.messageReceipts).flatMap(({ key, userReceipt }) =>
|
|
userReceipt.flatMap(receipt => ({ key, receipt }))
|
|
)
|
|
if (messageReceiptList.length) {
|
|
map['message-receipt.update'] = messageReceiptList
|
|
}
|
|
|
|
const contactUpsertList = Object.values(data.contactUpserts)
|
|
if (contactUpsertList.length) {
|
|
map['contacts.upsert'] = contactUpsertList
|
|
}
|
|
|
|
const contactUpdateList = Object.values(data.contactUpdates)
|
|
if (contactUpdateList.length) {
|
|
map['contacts.update'] = contactUpdateList
|
|
}
|
|
|
|
const groupUpdateList = Object.values(data.groupUpdates)
|
|
if (groupUpdateList.length) {
|
|
map['groups.update'] = groupUpdateList
|
|
}
|
|
|
|
const lidMappingList = Object.values(data.lidMappings)
|
|
if (lidMappingList.length) {
|
|
map['lid-mapping.update'] = lidMappingList
|
|
}
|
|
|
|
return map
|
|
}
|
|
|
|
// ============================================================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================================================
|
|
|
|
function concatChats<C extends Partial<Chat>>(a: C, b: Partial<Chat>) {
|
|
if (
|
|
b.unreadCount === null && // neutralize unread counter
|
|
(a.unreadCount ?? 0) < 0
|
|
) {
|
|
a.unreadCount = undefined
|
|
b.unreadCount = undefined
|
|
}
|
|
|
|
if (typeof a.unreadCount === 'number' && typeof b.unreadCount === 'number') {
|
|
b = { ...b }
|
|
if ((b.unreadCount ?? 0) >= 0) {
|
|
b.unreadCount = Math.max(b.unreadCount ?? 0, 0) + Math.max(a.unreadCount, 0)
|
|
}
|
|
}
|
|
|
|
return Object.assign(a, b)
|
|
}
|
|
|
|
const stringifyMessageKey = (key: WAMessageKey) => `${key.remoteJid},${key.id},${key.fromMe ? '1' : '0'}`
|