feat: Session cleanup with activity tracking - Automated cleanup of inactive/orphaned Signal sessions

feat: Session cleanup with activity tracking - Automated cleanup of inactive/orphaned Signal sessions
This commit is contained in:
Renato Alcara
2026-02-10 07:03:45 -03:00
committed by GitHub
8 changed files with 1866 additions and 0 deletions
+21
View File
@@ -194,6 +194,27 @@ export const DEFAULT_CACHE_MAX_KEYS = {
LID_GLOBAL: 10_000
}
/**
* Session cleanup configuration - removes inactive/orphaned Signal sessions
* Prevents unbounded database growth while maintaining security
*
* Environment variables:
* - BAILEYS_SESSION_CLEANUP_ENABLED: Enable/disable cleanup (default: true)
* - BAILEYS_SESSION_CLEANUP_INTERVAL: Cleanup interval in ms (default: 24h)
* - BAILEYS_SESSION_CLEANUP_HOUR: Hour to run cleanup (default: 3 = 3am)
* - BAILEYS_SESSION_SECONDARY_INACTIVE_DAYS: Days before cleaning secondary devices (default: 15)
* - BAILEYS_SESSION_PRIMARY_INACTIVE_DAYS: Days before cleaning primary device (default: 30)
* - BAILEYS_SESSION_LID_ORPHAN_HOURS: Hours before cleaning orphaned LID sessions (default: 24)
*/
export const DEFAULT_SESSION_CLEANUP_CONFIG = {
enabled: process.env.BAILEYS_SESSION_CLEANUP_ENABLED !== 'false',
intervalMs: parseInt(process.env.BAILEYS_SESSION_CLEANUP_INTERVAL || '86400000', 10), // 24h
cleanupHour: parseInt(process.env.BAILEYS_SESSION_CLEANUP_HOUR || '3', 10), // 3am
secondaryDeviceInactiveDays: parseInt(process.env.BAILEYS_SESSION_SECONDARY_INACTIVE_DAYS || '15', 10),
primaryDeviceInactiveDays: parseInt(process.env.BAILEYS_SESSION_PRIMARY_INACTIVE_DAYS || '30', 10),
lidOrphanHours: parseInt(process.env.BAILEYS_SESSION_LID_ORPHAN_HOURS || '24', 10)
}
// Re-export retry constants for backwards compatibility
// Actual definitions are in retry-utils.ts to avoid ESM initialization order issues
export { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Utils/retry-utils'
+274
View File
@@ -0,0 +1,274 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
/**
* Session activity tracker configuration
*/
export interface SessionActivityConfig {
/** Flush interval in milliseconds (default: 60s) */
flushIntervalMs: number
/** Enable activity tracking (default: true) */
enabled: boolean
}
/**
* Default configuration for session activity tracking
*/
export const DEFAULT_SESSION_ACTIVITY_CONFIG: SessionActivityConfig = {
flushIntervalMs: parseInt(process.env.BAILEYS_SESSION_ACTIVITY_FLUSH_MS || '60000', 10), // 1 minute
enabled: process.env.BAILEYS_SESSION_ACTIVITY_ENABLED !== 'false'
}
/**
* Session activity metadata stored in key store
*/
export interface SessionActivityMetadata {
/** Last activity timestamp (Unix milliseconds) */
lastActivityAt: number
/** Session created timestamp (Unix milliseconds) */
createdAt?: number
}
/**
* Session Activity Tracker
*
* Tracks when sessions were last used (message sent/received) to enable
* cleanup of inactive sessions.
*
* PERFORMANCE OPTIMIZATION:
* - In-memory cache: Updates stored in Map (no disk I/O per message)
* - Periodic flush: Writes to disk every 60s (configurable)
* - Batch writes: All updates written in single transaction
*
* OVERHEAD: <0.1ms per message (just Map.set() in memory)
*
* @example
* const tracker = makeSessionActivityTracker(keys, logger)
* tracker.start()
*
* // On message send/receive:
* tracker.recordActivity('5511999999999@s.whatsapp.net')
*
* // On cleanup:
* const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
* if (Date.now() - lastActivity > 30_DAYS) {
* // Delete session
* }
*/
export const makeSessionActivityTracker = (
keys: SignalKeyStoreWithTransaction,
logger: ILogger,
config: SessionActivityConfig = DEFAULT_SESSION_ACTIVITY_CONFIG
) => {
// In-memory cache: JID -> timestamp
const activityCache = new Map<string, number>()
// Dirty flag: tracks if cache has unflushed changes
let isDirty = false
// Flush interval timer
let flushInterval: ReturnType<typeof setInterval> | null = null
// Statistics
let stats = {
totalUpdates: 0,
totalFlushes: 0,
lastFlushAt: 0,
lastFlushDuration: 0,
cacheSize: 0
}
/**
* Record activity for a JID
* Updates in-memory cache only (fast, <0.1ms)
*/
const recordActivity = (jid: string): void => {
if (!config.enabled) return
const now = Date.now()
activityCache.set(jid, now)
isDirty = true
stats.totalUpdates++
stats.cacheSize = activityCache.size
}
/**
* Get last activity timestamp for a JID
* Checks in-memory cache first, then disk
*/
const getLastActivity = async (jid: string): Promise<number | undefined> => {
if (!config.enabled) return undefined
// Check cache first
const cached = activityCache.get(jid)
if (cached) return cached
// Fallback to disk
try {
const key = `session-activity:${jid}`
const data = await keys.get('session-activity' as any, [key])
const metadata = data[key] as SessionActivityMetadata | undefined
return metadata?.lastActivityAt
} catch (error) {
logger.warn({ error, jid }, 'Failed to get session activity from disk')
return undefined
}
}
/**
* Get all session activities (for cleanup)
* Returns Map of JID -> lastActivityAt
*/
const getAllActivities = async (): Promise<Map<string, number>> => {
if (!config.enabled) return new Map()
const result = new Map<string, number>()
try {
// Get all session activity keys from disk
const allData = await keys.get('session-activity' as any, [])
for (const [key, value] of Object.entries(allData)) {
if (key.startsWith('session-activity:')) {
const jid = key.replace('session-activity:', '')
const metadata = value as SessionActivityMetadata
if (metadata?.lastActivityAt) {
result.set(jid, metadata.lastActivityAt)
}
}
}
// Merge with in-memory cache (cache is more recent)
for (const [jid, timestamp] of activityCache.entries()) {
result.set(jid, timestamp)
}
} catch (error) {
logger.warn({ error }, 'Failed to get all session activities')
}
return result
}
/**
* Flush in-memory cache to disk
* Writes all pending updates in single transaction
*/
const flush = async (): Promise<void> => {
if (!config.enabled || !isDirty || activityCache.size === 0) {
return
}
const startTime = Date.now()
try {
// Prepare batch update
const updates: { [key: string]: SessionActivityMetadata } = {}
for (const [jid, timestamp] of activityCache.entries()) {
const key = `session-activity:${jid}`
updates[key] = {
lastActivityAt: timestamp,
createdAt: timestamp // First activity = creation time
}
}
// Single transaction for all updates
await keys.transaction(async () => {
await keys.set({ 'session-activity': updates } as any)
}, 'session-activity-flush')
isDirty = false
stats.totalFlushes++
stats.lastFlushAt = Date.now()
stats.lastFlushDuration = Date.now() - startTime
logger.debug(
{
count: activityCache.size,
duration: stats.lastFlushDuration
},
'💾 Session activity flushed to disk'
)
// Clear cache after successful flush
activityCache.clear()
stats.cacheSize = 0
} catch (error) {
logger.error({ error, count: activityCache.size }, 'Failed to flush session activity')
// Keep cache for retry on next flush
}
}
/**
* Start periodic flush
*/
const start = (): void => {
if (!config.enabled) {
logger.info('Session activity tracking is disabled')
return
}
if (flushInterval) {
logger.warn('Session activity tracker already started')
return
}
logger.info(
{
flushIntervalMs: config.flushIntervalMs,
flushIntervalSeconds: config.flushIntervalMs / 1000
},
'⏱️ Session activity tracker started'
)
// Flush immediately on start (recover any pending data)
flush().catch(err => {
logger.warn({ err }, 'Initial flush failed')
})
// Schedule periodic flush
flushInterval = setInterval(() => {
flush().catch(err => {
logger.warn({ err }, 'Periodic flush failed')
})
}, config.flushIntervalMs)
}
/**
* Stop periodic flush and flush pending data
*/
const stop = async (): Promise<void> => {
if (flushInterval) {
clearInterval(flushInterval)
flushInterval = null
}
// Final flush before stopping
await flush()
logger.info('Session activity tracker stopped')
}
/**
* Get tracker statistics
*/
const getStats = () => ({
enabled: config.enabled,
...stats
})
return {
recordActivity,
getLastActivity,
getAllActivities,
flush,
start,
stop,
getStats
}
}
/**
* Session activity tracker type
*/
export type SessionActivityTracker = ReturnType<typeof makeSessionActivityTracker>
+444
View File
@@ -0,0 +1,444 @@
import { DEFAULT_SESSION_CLEANUP_CONFIG } from '../Defaults'
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { ILogger } from '../Utils/logger'
import { jidDecode } from '../WABinary'
import type { LIDMappingStore } from './lid-mapping'
import type { SessionActivityTracker } from './session-activity-tracker'
/**
* Session cleanup statistics
*/
export interface SessionCleanupStats {
totalScanned: number
secondaryDevicesDeleted: number
primaryDevicesDeleted: number
lidOrphansDeleted: number
totalDeleted: number
durationMs: number
errors: number
}
/**
* Session cleanup configuration
*/
export interface SessionCleanupConfig {
enabled: boolean
intervalMs: number
cleanupHour: number
secondaryDeviceInactiveDays: number
primaryDeviceInactiveDays: number
lidOrphanHours: number
}
/**
* Session metadata for cleanup decisions
*/
interface SessionMetadata {
jid: string
isLID: boolean
isPrimary: boolean
lastActivityMs?: number
createdAtMs?: number
hasLIDMapping?: boolean
}
/**
* Creates a session cleanup manager
*
* SAFETY GUARANTEES:
* - Does NOT affect WebSocket connections (only local database)
* - Does NOT cause message loss (Signal Protocol auto-recreates sessions)
* - Runs in low-traffic hours (configurable, default 3am)
* - Atomic transactions (all-or-nothing)
* - Comprehensive logging and statistics
*
* CLEANUP RULES:
* 1. Secondary devices (Web, Desktop) - Inactive > X days (default: 15)
* 2. Primary devices - Inactive > Y days (default: 30)
* 3. LID orphans (no PN mapping) - Inactive > Z hours (default: 24)
*
* @param keys - Signal key store with transaction support
* @param lidMapping - LID mapping store for orphan detection
* @param sessionActivityTracker - Session activity tracker for timestamp-based cleanup
* @param logger - Structured logger instance
* @param config - Cleanup configuration (uses defaults from env)
*/
export const makeSessionCleanup = (
keys: SignalKeyStoreWithTransaction,
lidMapping: LIDMappingStore,
sessionActivityTracker: SessionActivityTracker | null,
logger: ILogger,
config: SessionCleanupConfig = DEFAULT_SESSION_CLEANUP_CONFIG
) => {
let cleanupInterval: ReturnType<typeof setInterval> | null = null
let lastCleanupAt: number = 0
let cleanupRunning: boolean = false
/**
* Get all sessions from database
* Returns array of session keys (signal addresses)
*/
const getAllSessionKeys = async (): Promise<string[]> => {
try {
// Get all sessions from key store
// Signal addresses format: "user_domain.device"
const sessions = await keys.get('session', [])
return Object.keys(sessions)
} catch (error) {
logger.error({ error }, 'Failed to get all session keys')
return []
}
}
/**
* Parse session metadata from signal address
* Signal address format: "user_domain.device"
* Examples:
* "5511999999999_0.0" → PN primary device
* "5511999999999_0.1" → PN secondary device
* "123456789_2.0" → LID primary device
*/
const parseSessionMetadata = async (signalAddr: string): Promise<SessionMetadata | null> => {
try {
// Parse signal address: "user_domain.device"
const [userWithDomain, deviceStr] = signalAddr.split('.')
if (!userWithDomain) return null
const [user, domainStr] = userWithDomain.split('_')
if (!user) return null
const device = parseInt(deviceStr || '0', 10)
const domain = parseInt(domainStr || '0', 10)
// Determine if LID (domain 2 or 6) or PN (domain 0 or 5)
const isLID = domain === 2 || domain === 6
const isPrimary = device === 0
// Construct JID for mapping lookup
let jid: string
if (isLID) {
jid = `${user}@lid`
} else {
jid = `${user}${device > 0 ? `:${device}` : ''}@s.whatsapp.net`
}
// Check if LID has PN mapping
let hasLIDMapping: boolean | undefined
if (isLID) {
const pn = await lidMapping.getPNForLID(jid)
hasLIDMapping = !!pn
}
return {
jid,
isLID,
isPrimary,
hasLIDMapping
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Failed to parse session metadata')
return null
}
}
/**
* Determine if session should be cleaned up
*/
const shouldCleanupSession = (metadata: SessionMetadata, now: number): { cleanup: boolean; reason: string } => {
// Rule 1: LID orphans (no PN mapping) after X hours
if (metadata.isLID && metadata.hasLIDMapping === false) {
const thresholdMs = config.lidOrphanHours * 60 * 60 * 1000
// Check if lastActivity is old enough
if (metadata.lastActivityMs) {
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `LID orphan without PN mapping, inactive for ${Math.floor(inactiveDuration / 3600000)}h (threshold: ${config.lidOrphanHours}h)`
}
}
} else {
// No activity tracked - consider it old
return {
cleanup: true,
reason: `LID orphan without PN mapping, no activity tracked (threshold: ${config.lidOrphanHours}h)`
}
}
return { cleanup: false, reason: 'LID orphan but not inactive long enough' }
}
// Rule 2: Secondary devices inactive > X days
if (!metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.secondaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Secondary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.secondaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Secondary device active within ${config.secondaryDeviceInactiveDays} days` }
}
// Rule 3: Primary devices inactive > Y days
if (metadata.isPrimary && metadata.lastActivityMs) {
const thresholdMs = config.primaryDeviceInactiveDays * 24 * 60 * 60 * 1000
const inactiveDuration = now - metadata.lastActivityMs
if (inactiveDuration > thresholdMs) {
return {
cleanup: true,
reason: `Primary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.primaryDeviceInactiveDays} days)`
}
}
return { cleanup: false, reason: `Primary device active within ${config.primaryDeviceInactiveDays} days` }
}
// No lastActivity tracked - don't cleanup yet (grace period)
return { cleanup: false, reason: 'No activity tracked yet' }
}
/**
* Run session cleanup
* Returns statistics about cleanup operation
*/
const runCleanup = async (): Promise<SessionCleanupStats> => {
const startTime = Date.now()
const stats: SessionCleanupStats = {
totalScanned: 0,
secondaryDevicesDeleted: 0,
primaryDevicesDeleted: 0,
lidOrphansDeleted: 0,
totalDeleted: 0,
durationMs: 0,
errors: 0
}
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return stats
}
if (cleanupRunning) {
logger.warn('Session cleanup already running, skipping')
return stats
}
cleanupRunning = true
logger.info('🧹 Starting session cleanup')
try {
// Get all session keys
const sessionKeys = await getAllSessionKeys()
stats.totalScanned = sessionKeys.length
logger.info({ totalSessions: sessionKeys.length }, 'Scanning sessions for cleanup')
// Get all activity timestamps (if tracker available)
const activityMap = sessionActivityTracker
? await sessionActivityTracker.getAllActivities()
: new Map<string, number>()
logger.debug(
{ trackedActivities: activityMap.size },
'Loaded session activity timestamps'
)
// Prepare bulk deletion
const sessionsToDelete: string[] = []
const deletionReasons: { [key: string]: string } = {}
// Process each session
for (const signalAddr of sessionKeys) {
try {
const metadata = await parseSessionMetadata(signalAddr)
if (!metadata) {
stats.errors++
continue
}
// Add lastActivity from tracker
metadata.lastActivityMs = activityMap.get(metadata.jid)
const { cleanup, reason } = shouldCleanupSession(metadata, Date.now())
if (cleanup) {
sessionsToDelete.push(signalAddr)
deletionReasons[signalAddr] = reason
// Update statistics
if (metadata.isLID && !metadata.hasLIDMapping) {
stats.lidOrphansDeleted++
} else if (!metadata.isPrimary) {
stats.secondaryDevicesDeleted++
} else {
stats.primaryDevicesDeleted++
}
logger.debug(
{
jid: metadata.jid,
signalAddr,
reason,
isLID: metadata.isLID,
isPrimary: metadata.isPrimary
},
'Session marked for deletion'
)
}
} catch (error) {
logger.warn({ error, signalAddr }, 'Error processing session for cleanup')
stats.errors++
}
}
// Bulk delete sessions
if (sessionsToDelete.length > 0) {
logger.info({ count: sessionsToDelete.length }, '🗑️ Deleting orphaned/inactive sessions')
try {
// Prepare session updates (set to null = delete)
const sessionUpdates: { [key: string]: null } = {}
sessionsToDelete.forEach(addr => {
sessionUpdates[addr] = null
})
// Single atomic transaction for all deletions
await keys.transaction(async () => {
await keys.set({ session: sessionUpdates })
}, 'session-cleanup')
stats.totalDeleted = sessionsToDelete.length
logger.info(
{
deleted: stats.totalDeleted,
lidOrphans: stats.lidOrphansDeleted,
secondaryDevices: stats.secondaryDevicesDeleted,
primaryDevices: stats.primaryDevicesDeleted,
errors: stats.errors
},
'✅ Session cleanup completed successfully'
)
} catch (error) {
logger.error({ error, count: sessionsToDelete.length }, '❌ Failed to delete sessions')
stats.errors++
}
} else {
logger.info('No sessions to cleanup')
}
lastCleanupAt = Date.now()
stats.durationMs = Date.now() - startTime
return stats
} catch (error) {
logger.error({ error }, '❌ Session cleanup failed')
stats.errors++
stats.durationMs = Date.now() - startTime
return stats
} finally {
cleanupRunning = false
}
}
/**
* Calculate milliseconds until next cleanup time
* Ensures cleanup runs at configured hour (default: 3am)
*/
const msUntilNextCleanup = (): number => {
const now = new Date()
const next = new Date()
next.setHours(config.cleanupHour, 0, 0, 0)
// If we're past cleanup hour today, schedule for tomorrow
if (now.getHours() >= config.cleanupHour) {
next.setDate(next.getDate() + 1)
}
return next.getTime() - now.getTime()
}
/**
* Start periodic session cleanup
* Runs at configured hour (default: 3am daily)
*/
const start = () => {
if (!config.enabled) {
logger.info('Session cleanup is disabled')
return
}
if (cleanupInterval) {
logger.warn('Session cleanup already started')
return
}
logger.info(
{
enabled: config.enabled,
intervalHours: config.intervalMs / (60 * 60 * 1000),
cleanupHour: config.cleanupHour,
secondaryDeviceInactiveDays: config.secondaryDeviceInactiveDays,
primaryDeviceInactiveDays: config.primaryDeviceInactiveDays,
lidOrphanHours: config.lidOrphanHours
},
'🧹 Session cleanup scheduler started'
)
// Schedule first cleanup at configured hour
const msUntilFirst = msUntilNextCleanup()
logger.info(
{ msUntilFirst, nextCleanup: new Date(Date.now() + msUntilFirst).toISOString() },
'⏰ First cleanup scheduled'
)
setTimeout(async () => {
// Run first cleanup
await runCleanup()
// Schedule recurring cleanup
cleanupInterval = setInterval(async () => {
await runCleanup()
}, config.intervalMs)
}, msUntilFirst)
}
/**
* Stop periodic session cleanup
*/
const stop = () => {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
logger.info('Session cleanup scheduler stopped')
}
}
/**
* Get cleanup statistics
*/
const getStats = () => ({
enabled: config.enabled,
lastCleanupAt,
cleanupRunning,
config
})
return {
start,
stop,
runCleanup,
getStats
}
}
/**
* Session cleanup manager type
*/
export type SessionCleanupManager = ReturnType<typeof makeSessionCleanup>
+11
View File
@@ -87,6 +87,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
notificationMutex,
receiptMutex,
signalRepository,
sessionActivityTracker,
query,
upsertMessage,
resyncAppState,
@@ -1531,6 +1532,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
: msgContent?.reactionMessage ? 'reaction'
: 'other'
recordMessageReceived(msgType)
// Track session activity for cleanup
if (sessionActivityTracker && msg.key.remoteJid) {
sessionActivityTracker.recordActivity(msg.key.remoteJid)
// For groups, also track participant activity
if (msg.key.participant) {
sessionActivityTracker.recordActivity(msg.key.participant)
}
}
})
} catch (error) {
logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message')
+14
View File
@@ -86,6 +86,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
authState,
messageMutex,
signalRepository,
sessionActivityTracker,
upsertMessage,
query,
fetchPrivacySettings,
@@ -1511,6 +1512,19 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (messageRetryManager && !participant) {
messageRetryManager.addRecentMessage(destinationJid, msgId, message)
}
// Track session activity for cleanup (all target JIDs)
if (sessionActivityTracker) {
// Record activity for destination JID
sessionActivityTracker.recordActivity(destinationJid)
// For groups, also record activity for all participants who received the message
if (isGroup || isStatus) {
for (const device of devices) {
sessionActivityTracker.recordActivity(device.jid)
}
}
}
}, meId)
return msgId
+29
View File
@@ -6,12 +6,15 @@ import { proto } from '../../WAProto/index.js'
import {
DEF_CALLBACK_PREFIX,
DEF_TAG_PREFIX,
DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
MIN_PREKEY_COUNT,
MIN_UPLOAD_INTERVAL,
NOISE_WA_HEADER,
UPLOAD_TIMEOUT
} from '../Defaults'
import { makeSessionCleanup } from '../Signal/session-cleanup'
import { makeSessionActivityTracker } from '../Signal/session-activity-tracker'
import type { ConnectionState, LIDMapping, SocketConfig } from '../Types'
import { DisconnectReason } from '../Types'
import {
@@ -499,6 +502,18 @@ export const makeSocket = (config: SocketConfig) => {
const keys = addTransactionCapability(authState.keys, logger, transactionOpts)
const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync)
// Session activity tracker - tracks last activity for cleanup (must be created first)
const sessionActivityTracker = makeSessionActivityTracker(keys, logger)
// Session cleanup manager - removes inactive/orphaned sessions
const sessionCleanup = makeSessionCleanup(
keys,
signalRepository.lidMapping,
sessionActivityTracker,
logger,
DEFAULT_SESSION_CLEANUP_CONFIG
)
let lastDateRecv: Date
let epoch = 1
let keepAliveReq: NodeJS.Timeout
@@ -1079,6 +1094,12 @@ export const makeSocket = (config: SocketConfig) => {
clearInterval(keepAliveReq)
clearTimeout(qrTimer)
// Stop session cleanup scheduler
sessionCleanup.stop()
// Stop session activity tracker and flush pending data
await sessionActivityTracker.stop()
// Clean up unified session manager
unifiedSessionManager?.destroy()
@@ -1448,6 +1469,12 @@ export const makeSocket = (config: SocketConfig) => {
recordConnectionAttempt('success')
incrementActiveConnections()
// Start session cleanup scheduler
sessionCleanup.start()
// Start session activity tracker
sessionActivityTracker.start()
// Update server time offset from success node
const serverTime = extractServerTime(node)
if (serverTime) {
@@ -1572,6 +1599,8 @@ export const makeSocket = (config: SocketConfig) => {
ev,
authState: { creds, keys },
signalRepository,
sessionCleanup,
sessionActivityTracker,
get user() {
return authState.creds.me
},
@@ -0,0 +1,544 @@
import { jest } from '@jest/globals'
import P from 'pino'
import { makeSessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { SessionActivityMetadata } from '../../Signal/session-activity-tracker'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn() as any,
set: jest.fn() as any,
transaction: jest.fn(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn() as any
}
const logger = P({ level: 'silent' })
describe('SessionActivityTracker', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
describe('recordActivity', () => {
it('should record activity in memory cache', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1)
expect(stats.cacheSize).toBe(1)
})
it('should update existing activity timestamp', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
tracker.recordActivity(jid)
jest.advanceTimersByTime(5000) // 5 seconds later
tracker.recordActivity(jid)
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(1) // Still one unique JID
})
it('should not record activity when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(0)
expect(stats.cacheSize).toBe(0)
})
it('should handle multiple JIDs', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
tracker.recordActivity('123456789@lid')
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(3)
expect(stats.cacheSize).toBe(3)
})
})
describe('getLastActivity', () => {
it('should return activity from cache (cache hit)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const beforeTime = Date.now()
tracker.recordActivity(jid)
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBeGreaterThanOrEqual(beforeTime)
expect(mockKeys.get).not.toHaveBeenCalled() // Cache hit, no DB call
})
it('should fallback to disk when not in cache (cache miss)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
const lastActivity = await tracker.getLastActivity(jid)
expect(lastActivity).toBe(diskTimestamp)
expect(mockKeys.get).toHaveBeenCalledTimes(1)
})
it('should return undefined when activity not found', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const lastActivity = await tracker.getLastActivity('nonexistent@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should return undefined when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
it('should handle disk read errors gracefully', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
const lastActivity = await tracker.getLastActivity('5511999999999@s.whatsapp.net')
expect(lastActivity).toBeUndefined()
})
})
describe('getAllActivities', () => {
it('should return all activities from disk and cache', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const now = Date.now()
// Simulate disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
'session-activity:5511999999999@s.whatsapp.net': {
lastActivityAt: now - 10000,
createdAt: now - 20000
} as SessionActivityMetadata,
'session-activity:5511888888888@s.whatsapp.net': {
lastActivityAt: now - 5000,
createdAt: now - 15000
} as SessionActivityMetadata
})
// Add new activity to cache
tracker.recordActivity('123456789@lid')
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(3)
expect(activities.has('5511999999999@s.whatsapp.net')).toBe(true)
expect(activities.has('5511888888888@s.whatsapp.net')).toBe(true)
expect(activities.has('123456789@lid')).toBe(true)
})
it('should prioritize cache over disk (cache is more recent)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
const diskTimestamp = Date.now() - 10000
// Simulate old disk data
// @ts-ignore
mockKeys.get.mockResolvedValue({
[`session-activity:${jid}`]: {
lastActivityAt: diskTimestamp,
createdAt: diskTimestamp
} as SessionActivityMetadata
})
// Record new activity in cache
const beforeCache = Date.now()
tracker.recordActivity(jid)
const activities = await tracker.getAllActivities()
const cacheTimestamp = activities.get(jid)
expect(cacheTimestamp).toBeGreaterThanOrEqual(beforeCache)
expect(cacheTimestamp).not.toBe(diskTimestamp) // Cache wins
})
it('should return empty map when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const activities = await tracker.getAllActivities()
expect(activities.size).toBe(0)
})
it('should handle disk read errors gracefully', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Simulate disk error
// @ts-ignore
mockKeys.get.mockRejectedValue(new Error('Disk read error'))
// getAllActivities should return empty map (logs warning but doesn't throw)
const activities = await tracker.getAllActivities()
expect(activities).toBeInstanceOf(Map)
expect(activities.size).toBe(0) // Current implementation doesn't return cache on disk error
// Restore fake timers
jest.useFakeTimers()
})
})
describe('flush', () => {
it('should flush cache to disk in batch', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid1 = '5511999999999@s.whatsapp.net'
const jid2 = '5511888888888@s.whatsapp.net'
tracker.recordActivity(jid1)
tracker.recordActivity(jid2)
await tracker.flush()
// Should call transaction once for batch write
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
// Verify batch update structure
const setCall = mockKeys.set.mock.calls[0]?.[0]
// @ts-ignore
expect(setCall['session-activity']).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid1}`]).toBeDefined()
// @ts-ignore
expect(setCall['session-activity'][`session-activity:${jid2}`]).toBeDefined()
})
it('should clear cache after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(0) // Cache cleared
expect(stats.totalFlushes).toBe(1)
})
it('should not flush when cache is empty', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not flush when disabled', async () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
await tracker.flush()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should keep cache on flush failure (retry on next flush)', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Simulate flush error
mockKeys.transaction.mockRejectedValueOnce(new Error('Flush failed'))
await tracker.flush()
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Cache NOT cleared
expect(stats.totalFlushes).toBe(0) // Flush failed
})
it('should update statistics after successful flush', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
const beforeFlush = Date.now()
await tracker.flush()
const afterFlush = Date.now()
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
expect(stats.lastFlushAt).toBeGreaterThanOrEqual(beforeFlush)
expect(stats.lastFlushAt).toBeLessThanOrEqual(afterFlush)
expect(stats.lastFlushDuration).toBeGreaterThanOrEqual(0)
})
})
describe('start/stop lifecycle', () => {
it('should start periodic flush', async () => {
// Use real timers for this test
jest.useRealTimers()
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity so there's something to flush
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.start()
// Wait for initial flush to complete
await new Promise(resolve => setTimeout(resolve, 50))
// Should attempt initial flush
expect(mockKeys.transaction).toHaveBeenCalled()
// Cleanup
await tracker.stop()
// Restore fake timers
jest.useFakeTimers()
})
it('should flush periodically after start', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Advance time to trigger flush
jest.advanceTimersByTime(60000)
await Promise.resolve() // Let flush promise resolve
expect(mockKeys.transaction).toHaveBeenCalled()
})
it('should not start when disabled', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should not start twice (guard against multiple start calls)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Second start should be ignored
tracker.start()
// Should not trigger another initial flush
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
it('should stop periodic flush and flush pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Stop should flush pending data
await tracker.stop()
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalled()
})
it('should stop periodic flush without error when no pending data', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.start()
jest.clearAllMocks()
await tracker.stop()
// No flush needed (no pending data)
expect(mockKeys.transaction).not.toHaveBeenCalled()
})
})
describe('getStats', () => {
it('should return current statistics', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
tracker.recordActivity('5511999999999@s.whatsapp.net')
tracker.recordActivity('5511888888888@s.whatsapp.net')
const stats = tracker.getStats()
expect(stats.enabled).toBe(true)
expect(stats.totalUpdates).toBe(2)
expect(stats.cacheSize).toBe(2)
expect(stats.totalFlushes).toBe(0) // No flush yet
expect(stats.lastFlushAt).toBe(0)
expect(stats.lastFlushDuration).toBe(0)
})
it('should show disabled status', () => {
const config = { enabled: false, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const stats = tracker.getStats()
expect(stats.enabled).toBe(false)
})
})
describe('Performance', () => {
it('should handle high-volume activity recording (1000 messages)', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const startTime = Date.now()
// Simulate 1000 messages
for (let i = 0; i < 1000; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
const duration = Date.now() - startTime
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(1000)
expect(stats.cacheSize).toBe(1000)
// Should be very fast (<100ms for 1000 updates)
expect(duration).toBeLessThan(100)
})
it('should batch flush multiple activities in single transaction', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record 100 activities
for (let i = 0; i < 100; i++) {
tracker.recordActivity(`5511${i.toString().padStart(9, '0')}@s.whatsapp.net`)
}
await tracker.flush()
// Should use single transaction for all 100
expect(mockKeys.transaction).toHaveBeenCalledTimes(1)
expect(mockKeys.set).toHaveBeenCalledTimes(1)
const stats = tracker.getStats()
expect(stats.totalFlushes).toBe(1)
})
})
describe('Edge Cases', () => {
it('should handle JID with special characters', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const specialJids = [
'5511999999999:1@s.whatsapp.net', // Device ID
'123456789@lid', // LID
'5511999999999:99@hosted', // Hosted device
'123456789:5@hosted.lid' // Hosted LID
]
specialJids.forEach(jid => tracker.recordActivity(jid))
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(4)
})
it('should handle rapid updates to same JID', () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
const jid = '5511999999999@s.whatsapp.net'
// Rapid updates (10 in quick succession)
for (let i = 0; i < 10; i++) {
tracker.recordActivity(jid)
}
const stats = tracker.getStats()
expect(stats.totalUpdates).toBe(10)
expect(stats.cacheSize).toBe(1) // Still one JID
})
it('should handle flush during active recording', async () => {
const config = { enabled: true, flushIntervalMs: 60000 }
const tracker = makeSessionActivityTracker(mockKeys, logger, config)
// Record activity
tracker.recordActivity('5511999999999@s.whatsapp.net')
// Flush clears the cache
await tracker.flush()
// Record new activity after flush
tracker.recordActivity('5511888888888@s.whatsapp.net')
// New activity should be in cache
const stats = tracker.getStats()
expect(stats.cacheSize).toBe(1) // Only the new one
})
})
})
@@ -0,0 +1,529 @@
import { jest } from '@jest/globals'
import P from 'pino'
import { makeSessionCleanup } from '../../Signal/session-cleanup'
import type { SessionActivityTracker } from '../../Signal/session-activity-tracker'
import type { LIDMappingStore } from '../../Signal/lid-mapping'
import type { SignalKeyStoreWithTransaction } from '../../Types'
const mockKeys: jest.Mocked<SignalKeyStoreWithTransaction> = {
get: jest.fn<SignalKeyStoreWithTransaction['get']>() as any,
set: jest.fn<SignalKeyStoreWithTransaction['set']>(),
transaction: jest.fn<SignalKeyStoreWithTransaction['transaction']>(async (work: () => any) => await work()) as any,
isInTransaction: jest.fn<SignalKeyStoreWithTransaction['isInTransaction']>()
}
const mockLidMapping: jest.Mocked<Pick<LIDMappingStore, 'getPNForLID'>> = {
getPNForLID: jest.fn<LIDMappingStore['getPNForLID']>() as any
}
const mockActivityTracker: jest.Mocked<Pick<SessionActivityTracker, 'getAllActivities'>> = {
getAllActivities: jest.fn<SessionActivityTracker['getAllActivities']>() as any
}
const logger = P({ level: 'silent' })
describe('SessionCleanup', () => {
const HOUR_MS = 3600000
const DAY_MS = 86400000
beforeEach(() => {
jest.clearAllMocks()
})
describe('LID Orphan Cleanup (24h threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete LID orphan after 24h of inactivity', async () => {
const now = Date.now()
const lastActivity = now - (25 * HOUR_MS) // 25 hours ago
// Mock sessions: 1 LID orphan
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Mock: No PN mapping (orphan)
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Mock: Activity 25h ago
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
expect(mockKeys.set).toHaveBeenCalledWith({
session: { '123456789_2.0': null }
})
})
it('should NOT delete LID orphan before 24h', async () => {
const now = Date.now()
const lastActivity = now - (23 * HOUR_MS) // 23 hours ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
expect(mockKeys.set).not.toHaveBeenCalled()
})
it('should delete LID orphan with no activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// No activity tracked
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete LID with valid PN mapping', async () => {
const now = Date.now()
const lastActivity = now - (25 * HOUR_MS)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
// Has PN mapping - not orphan
mockLidMapping.getPNForLID.mockResolvedValue('5511999999999@s.whatsapp.net')
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Secondary Device Cleanup (15 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete secondary device (Web/Desktop) after 15 days', async () => {
const now = Date.now()
const lastActivity = now - (16 * DAY_MS) // 16 days ago
// Mock: Secondary device (device ID = 1)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete secondary device before 15 days', async () => {
const now = Date.now()
const lastActivity = now - (14 * DAY_MS) // 14 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999:1@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should NOT delete secondary device without activity tracking', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.1': Buffer.from('session-data')
})
// No activity tracked - grace period
mockActivityTracker.getAllActivities.mockResolvedValue(new Map())
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.secondaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Primary Device Cleanup (30 days threshold)', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete primary device after 30 days', async () => {
const now = Date.now()
const lastActivity = now - (31 * DAY_MS) // 31 days ago
// Mock: Primary device (device ID = 0)
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(1)
})
it('should NOT delete primary device before 30 days', async () => {
const now = Date.now()
const lastActivity = now - (29 * DAY_MS) // 29 days ago
// @ts-ignore
mockKeys.get.mockResolvedValue({
'5511999999999_0.0': Buffer.from('session-data')
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['5511999999999@s.whatsapp.net', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.primaryDevicesDeleted).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
})
describe('Boundary Conditions', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should handle exactly 24h for LID orphan (boundary)', async () => {
const now = Date.now()
const lastActivity = now - (24 * HOUR_MS) // Exactly 24h
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([['123456789@lid', lastActivity]])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
// Exactly 24h should NOT delete (> threshold, not >=)
expect(stats.lidOrphansDeleted).toBe(0)
})
it('should handle empty session list', async () => {
// @ts-ignore
mockKeys.get.mockResolvedValue({})
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(stats.totalDeleted).toBe(0)
})
it('should handle null sessionActivityTracker gracefully', async () => {
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
// Pass null tracker
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
null,
logger,
config
)
const stats = await cleanup.runCleanup()
// Should still work, but with no activity data
expect(stats.totalScanned).toBe(1)
// LID orphan with no activity tracking gets deleted
expect(stats.lidOrphansDeleted).toBe(1)
})
})
describe('Mixed Scenarios', () => {
const config = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
it('should delete multiple sessions of different types', async () => {
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary-inactive'),
'5511888888888_0.0': Buffer.from('primary-inactive'),
'5511777777777_0.0': Buffer.from('primary-active')
})
mockLidMapping.getPNForLID.mockImplementation(async (lid: string) => {
if (lid === '123456789@lid') return null // Orphan
return '5511999999999@s.whatsapp.net'
})
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - (25 * HOUR_MS)], // LID orphan: 25h ago
['5511999999999:1@s.whatsapp.net', now - (16 * DAY_MS)], // Secondary: 16 days ago
['5511888888888@s.whatsapp.net', now - (31 * DAY_MS)], // Primary: 31 days ago
['5511777777777@s.whatsapp.net', now - (5 * DAY_MS)] // Primary: 5 days ago (active)
])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(4)
expect(stats.lidOrphansDeleted).toBe(1)
expect(stats.secondaryDevicesDeleted).toBe(1)
expect(stats.primaryDevicesDeleted).toBe(1)
expect(stats.totalDeleted).toBe(3)
})
})
describe('Configuration', () => {
it('should respect disabled cleanup', async () => {
const config = {
enabled: false,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 15,
primaryDeviceInactiveDays: 30,
lidOrphanHours: 24
}
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('session-data')
})
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
config
)
const stats = await cleanup.runCleanup()
expect(stats.totalScanned).toBe(0)
expect(mockKeys.get).not.toHaveBeenCalled()
})
it('should respect custom thresholds', async () => {
const customConfig = {
enabled: true,
intervalMs: 86400000,
cleanupHour: 3,
secondaryDeviceInactiveDays: 5, // Custom: 5 days
primaryDeviceInactiveDays: 10, // Custom: 10 days
lidOrphanHours: 12 // Custom: 12 hours
}
const now = Date.now()
// @ts-ignore
mockKeys.get.mockResolvedValue({
'123456789_2.0': Buffer.from('lid-orphan'),
'5511999999999_0.1': Buffer.from('secondary')
})
mockLidMapping.getPNForLID.mockResolvedValue(null)
mockActivityTracker.getAllActivities.mockResolvedValue(
new Map([
['123456789@lid', now - (13 * HOUR_MS)], // 13h ago
['5511999999999:1@s.whatsapp.net', now - (6 * DAY_MS)] // 6 days ago
])
)
const cleanup = makeSessionCleanup(
mockKeys,
mockLidMapping as any,
mockActivityTracker as any,
logger,
customConfig
)
const stats = await cleanup.runCleanup()
expect(stats.lidOrphansDeleted).toBe(1) // 13h > 12h threshold
expect(stats.secondaryDevicesDeleted).toBe(1) // 6d > 5d threshold
expect(stats.totalDeleted).toBe(2)
})
})
})